diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md index e18aae4c2c7..a9e9eb1a46f 100644 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ b/docs/reference/feature-discovery-interaction-tracking.md @@ -26,7 +26,7 @@ Do not upload this state as broad analytics. Product analytics should continue t | Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | | AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | | AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | -| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. | +| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. | | Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | | Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | | Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | @@ -62,6 +62,7 @@ Orca also records surface-level interactions for feature areas where opening the - `browser`: non-blank browser page viewed - `tasks`: Tasks page opened - `automations`: Automations page opened +- `floating-workspace`: floating workspace opened - `workspace-creation`: workspace creation flow opened These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?" diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 499673ad2e3..41af3f4c8d0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -63,6 +63,7 @@ import { isFloatingWorkspaceTerminalInputTarget, shouldMinimizeFloatingWorkspacePanelOnCloseShortcut } from '@/lib/floating-workspace-terminal-actions' +import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' import { DictationController } from './components/dictation/DictationController' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' @@ -281,6 +282,11 @@ function App(): React.JSX.Element { useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() const [floatingTerminalOpen, setFloatingTerminalOpen] = useState(false) + const floatingWorkspaceTourInteractionSnapshotRef = useRef<{ + wasPreviouslyInteracted?: boolean + persisted?: Promise + recordFeatureInteractionForTour: boolean + } | null>(null) // Why: Zustand actions are referentially stable, but each individual // useAppStore(s => s.someAction) still registers a subscription that React @@ -429,7 +435,9 @@ function App(): React.JSX.Element { // Why: recordFeatureInteraction updates Zustand subscribers; doing it // inside React's state updater logs a render-phase update warning. if (resolvedOpen && !floatingTerminalOpen) { - useAppStore.getState().recordFeatureInteraction('floating-workspace') + const state = useAppStore.getState() + floatingWorkspaceTourInteractionSnapshotRef.current = + createFloatingWorkspaceTourInteractionSnapshot(state) rememberFloatingTerminalReturnFocus() } else if (!resolvedOpen && floatingTerminalOpen) { restoreFloatingTerminalReturnFocus() @@ -1949,6 +1957,7 @@ function App(): React.JSX.Element { ) : null} diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts index 5ba4deeaa70..3117a52d441 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts @@ -40,6 +40,26 @@ describe('contextual tour gate', () => { expect(decision).toEqual({ kind: 'blocked', reason: 'missing-start-target' }) }) + it('can start the floating workspace tour from the non-empty surface fallback', () => { + const tour = getContextualTour('floating-workspace') + const fallbackSelector = + '[data-contextual-tour-target="floating-workspace-actions"], [data-contextual-tour-target="floating-workspace-surface"]' + const decision = getContextualTourRequestDecision({ + tour, + persistedUIReady: true, + autoEligible: true, + onboardingVisible: false, + seenIds: [], + sessionConsumed: false, + activeTourId: null, + activeModal: 'none', + blockingSurfaceVisible: false, + targetExists: (selector) => selector === fallbackSelector + }) + + expect(decision).toEqual({ kind: 'start', stepIndex: 0 }) + }) + it('returns null when selector lookup or measurement throws', () => { expect( getMeasurableContextualTourTarget('[', { diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.test.ts index 91938d2d664..8c5978320fd 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.test.ts @@ -84,4 +84,43 @@ describe('contextual tour panel position', () => { expect(position.top).toBe(465) expect(position.left).toBe(560) }) + + it('keeps the floating workspace surface fallback panel outside the taught surface', () => { + const targetRect = { left: 360, right: 1080, top: 96, bottom: 536, width: 720, height: 440 } + const position = clampContextualTourPanelPosition({ + targetRect, + viewport: { width: 1280, height: 720 }, + panel: { width: 320, height: 160 }, + preferredPlacement: 'left' + }) + + expect(position.placement).toBe('left') + expect(position.left + 320).toBeLessThanOrEqual(targetRect.left - 12) + }) + + it('flips a preferred side placement instead of clamping over the target', () => { + const targetRect = { left: 12, right: 360, top: 96, bottom: 536, width: 348, height: 440 } + const position = clampContextualTourPanelPosition({ + targetRect, + viewport: { width: 1280, height: 720 }, + panel: { width: 320, height: 160 }, + preferredPlacement: 'left' + }) + + expect(position.placement).toBe('right') + expect(position.left).toBeGreaterThanOrEqual(targetRect.right + 12) + }) + + it('uses side room when a preferred vertical placement cannot fit above or below', () => { + const targetRect = { left: 120, right: 220, top: 40, bottom: 680, width: 100, height: 640 } + const position = clampContextualTourPanelPosition({ + targetRect, + viewport: { width: 1280, height: 720 }, + panel: { width: 320, height: 160 }, + preferredPlacement: 'bottom' + }) + + expect(position.placement).toBe('right') + expect(position.left).toBeGreaterThanOrEqual(targetRect.right + 12) + }) }) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.ts b/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.ts index 3e9fa45ecca..bf46da4665a 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-panel-position.ts @@ -37,7 +37,15 @@ export function clampContextualTourPanelPosition(args: { let left: number let top: number if (args.preferredPlacement) { - placement = args.preferredPlacement + placement = resolvePreferredPlacement({ + preferredPlacement: args.preferredPlacement, + roomAbove, + roomBelow, + roomLeft, + roomRight, + panel, + gap + }) const preferredPosition = getUnclampedPanelPosition({ placement, targetRect, @@ -90,6 +98,54 @@ export function clampContextualTourPanelPosition(args: { return { left: clampedLeft, top: clampedTop, placement, arrowOffset } } +function resolvePreferredPlacement(args: { + preferredPlacement: ContextualTourPanelPlacement + roomAbove: number + roomBelow: number + roomLeft: number + roomRight: number + panel: PanelSize + gap: number +}): ContextualTourPanelPlacement { + const horizontalRoom = args.panel.width + args.gap + const verticalRoom = args.panel.height + args.gap + if (args.preferredPlacement === 'left') { + return args.roomLeft < horizontalRoom && args.roomRight >= horizontalRoom ? 'right' : 'left' + } + if (args.preferredPlacement === 'right') { + return args.roomRight < horizontalRoom && args.roomLeft >= horizontalRoom ? 'left' : 'right' + } + if (args.preferredPlacement === 'top') { + if (args.roomAbove >= verticalRoom) { + return 'top' + } + if (args.roomBelow >= verticalRoom) { + return 'bottom' + } + return getPreferredHorizontalPlacement(args) + } + if (args.roomBelow >= verticalRoom) { + return 'bottom' + } + if (args.roomAbove >= verticalRoom) { + return 'top' + } + return getPreferredHorizontalPlacement(args) +} + +function getPreferredHorizontalPlacement(args: { + roomLeft: number + roomRight: number + panel: PanelSize + gap: number +}): ContextualTourPanelPlacement { + const horizontalRoom = args.panel.width + args.gap + if (args.roomRight >= horizontalRoom || args.roomRight >= args.roomLeft) { + return 'right' + } + return 'left' +} + function getUnclampedPanelPosition(args: { placement: ContextualTourPanelPlacement targetRect: Pick diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts index d43cb0aaf4f..f6c7cffb929 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' -import { shouldRequestContextualTourAfterInteraction } from './use-contextual-tour' +import { describe, expect, it, vi } from 'vitest' +import { + createContextualTourInteractionSnapshot, + shouldRequestContextualTourAfterInteraction +} from './use-contextual-tour' import type { ContextualTourId } from '../../../../shared/contextual-tours' describe('shouldRequestContextualTourAfterInteraction', () => { @@ -40,3 +43,78 @@ describe('shouldRequestContextualTourAfterInteraction', () => { ).resolves.toBe(true) }) }) + +describe('createContextualTourInteractionSnapshot', () => { + it('records regular contextual-tour feature interactions before requesting', async () => { + const persisted = Promise.resolve() + const recordFeatureInteraction = vi.fn(() => persisted) + + const snapshot = createContextualTourInteractionSnapshot({ + id: 'tasks', + featureInteractions: {}, + recordFeatureInteraction, + recordFeatureInteractionForTour: true + }) + + expect(recordFeatureInteraction).toHaveBeenCalledWith('tasks') + expect(snapshot.wasPreviouslyInteracted).toBe(false) + await expect(snapshot.persisted).resolves.toBeUndefined() + }) + + it('reuses the floating workspace pre-open snapshot without double-recording', async () => { + const persisted = Promise.resolve() + const recordFeatureInteraction = vi.fn(() => Promise.resolve()) + + const snapshot = createContextualTourInteractionSnapshot({ + id: 'floating-workspace', + featureInteractions: { + 'floating-workspace': { + firstInteractedAt: 1, + interactionCount: 1 + } + }, + recordFeatureInteraction, + recordFeatureInteractionForTour: false, + featureInteractionPersisted: persisted, + wasFeaturePreviouslyInteracted: false + }) + + expect(recordFeatureInteraction).not.toHaveBeenCalled() + expect(snapshot.wasPreviouslyInteracted).toBe(false) + expect(snapshot.persisted).toBe(persisted) + }) + + it('marks existing floating workspace users from the explicit pre-open snapshot', () => { + const snapshot = createContextualTourInteractionSnapshot({ + id: 'floating-workspace', + featureInteractions: {}, + recordFeatureInteraction: vi.fn(() => Promise.resolve()), + recordFeatureInteractionForTour: false, + wasFeaturePreviouslyInteracted: true + }) + + expect(snapshot.wasPreviouslyInteracted).toBe(true) + }) + + it('can record after hydration while preserving an explicit pre-open snapshot', () => { + const persisted = Promise.resolve() + const recordFeatureInteraction = vi.fn(() => persisted) + + const snapshot = createContextualTourInteractionSnapshot({ + id: 'floating-workspace', + featureInteractions: { + 'floating-workspace': { + firstInteractedAt: 1, + interactionCount: 1 + } + }, + recordFeatureInteraction, + recordFeatureInteractionForTour: true, + wasFeaturePreviouslyInteracted: false + }) + + expect(recordFeatureInteraction).toHaveBeenCalledWith('floating-workspace') + expect(snapshot.wasPreviouslyInteracted).toBe(false) + expect(snapshot.persisted).toBe(persisted) + }) +}) diff --git a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts index 3cac4dba65d..158e43865f8 100644 --- a/src/renderer/src/components/contextual-tours/use-contextual-tour.ts +++ b/src/renderer/src/components/contextual-tours/use-contextual-tour.ts @@ -1,6 +1,9 @@ import { useEffect, useRef } from 'react' import type { ContextualTourId } from '../../../../shared/contextual-tours' -import { hasFeatureInteraction } from '../../../../shared/feature-interactions' +import { + hasFeatureInteraction, + type FeatureInteractionState +} from '../../../../shared/feature-interactions' import { useAppStore } from '@/store' const TOUR_SOURCES = { @@ -9,9 +12,34 @@ const TOUR_SOURCES = { browser: 'browser_visible', tasks: 'tasks_open', automations: 'automations_open', + 'floating-workspace': 'floating_workspace_visible', 'workspace-creation': 'workspace_creation_visible' } satisfies Record +export type UseContextualTourOptions = { + recordFeatureInteraction?: boolean | undefined + featureInteractionPersisted?: Promise | undefined + wasFeaturePreviouslyInteracted?: boolean | undefined +} + +export function createContextualTourInteractionSnapshot(args: { + id: ContextualTourId + featureInteractions: FeatureInteractionState + recordFeatureInteraction: (id: ContextualTourId) => Promise + recordFeatureInteractionForTour: boolean + featureInteractionPersisted?: Promise | undefined + wasFeaturePreviouslyInteracted?: boolean | undefined +}): { persisted: Promise; wasPreviouslyInteracted: boolean } { + const wasPreviouslyInteracted = + args.wasFeaturePreviouslyInteracted ?? hasFeatureInteraction(args.featureInteractions, args.id) + return { + wasPreviouslyInteracted, + persisted: args.recordFeatureInteractionForTour + ? args.recordFeatureInteraction(args.id) + : (args.featureInteractionPersisted ?? Promise.resolve()) + } +} + export async function shouldRequestContextualTourAfterInteraction(args: { id: ContextualTourId persisted: Promise @@ -25,8 +53,14 @@ export async function shouldRequestContextualTourAfterInteraction(args: { export function useContextualTour( id: ContextualTourId, enabled: boolean, - source: string = TOUR_SOURCES[id] + source: string = TOUR_SOURCES[id], + options: UseContextualTourOptions = {} ): void { + const { + recordFeatureInteraction: shouldRecordFeatureInteraction = true, + featureInteractionPersisted, + wasFeaturePreviouslyInteracted + } = options const requestContextualTour = useAppStore((s) => s.requestContextualTour) const suppressContextualTour = useAppStore((s) => s.suppressContextualTour) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) @@ -62,19 +96,32 @@ export function useContextualTour( ) { return } - const wasPreviouslyInteracted = hasFeatureInteraction( - useAppStore.getState().featureInteractions, - id - ) + const snapshot = createContextualTourInteractionSnapshot({ + id, + featureInteractions: useAppStore.getState().featureInteractions, + recordFeatureInteraction, + recordFeatureInteractionForTour: shouldRecordFeatureInteraction, + featureInteractionPersisted, + wasFeaturePreviouslyInteracted + }) enabledInteractionSnapshotRef.current = { id, source, - // Why: recording writes featureInteractions; subscribing here would retrigger - // this effect and repeatedly persist the same enabled source. - wasPreviouslyInteracted, - persisted: recordFeatureInteraction(id) + // Why: recording writes featureInteractions; subscribing here would + // retrigger this effect and repeatedly persist the same enabled source. + wasPreviouslyInteracted: snapshot.wasPreviouslyInteracted, + persisted: snapshot.persisted } - }, [enabled, id, persistedUIReady, recordFeatureInteraction, source]) + }, [ + enabled, + featureInteractionPersisted, + id, + persistedUIReady, + recordFeatureInteraction, + shouldRecordFeatureInteraction, + source, + wasFeaturePreviouslyInteracted + ]) useEffect(() => { // Why: source disable should end through the overlay so a shown tour gets diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index da130481f2d..647a8222f1b 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -100,7 +100,8 @@ const mocks = vi.hoisted(() => ({ setActiveTab: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), - setTabPaneExpanded: vi.fn() + setTabPaneExpanded: vi.fn(), + useContextualTour: vi.fn() })) const saveDialogBox = vi.hoisted(() => ({ @@ -186,6 +187,10 @@ vi.mock('@/components/ui/button', () => ({ } })) +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: mocks.useContextualTour +})) + vi.mock('@/components/ui/dialog', () => ({ Dialog: function Dialog(props: { children?: unknown }) { return props.children @@ -478,6 +483,17 @@ function findByProp(node: unknown, propName: string): ReactElementLike { return found } +function collectPropValues(node: unknown, propName: string): unknown[] { + const values: unknown[] = [] + visit(node, (entry) => { + const value = entry.props[propName] + if (value !== undefined) { + values.push(value) + } + }) + return values +} + function runEffects(): void { const layoutEffects = hookRuntime.layoutEffects.splice(0) for (const effect of layoutEffects) { @@ -502,10 +518,18 @@ async function flushAsyncWork(): Promise { await Promise.resolve() } -async function renderPanel(open: boolean, onOpenChange = vi.fn()): Promise { +async function renderPanel( + open: boolean, + onOpenChange = vi.fn(), + tourInteractionSnapshot?: { + wasPreviouslyInteracted: boolean + persisted?: Promise + recordFeatureInteractionForTour: boolean + } | null +): Promise { hookRuntime.index = 0 const { FloatingTerminalPanel } = await import('./FloatingTerminalPanel') - return FloatingTerminalPanel({ open, onOpenChange }) + return FloatingTerminalPanel({ open, onOpenChange, tourInteractionSnapshot }) } function getPanelStyleBounds(element: unknown): FloatingTerminalPanelBounds { @@ -897,6 +921,93 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.createTab).not.toHaveBeenCalled() }) + it('requests the floating workspace tour only when the panel is open', async () => { + const persisted = Promise.resolve() + + await renderPanel(false, vi.fn(), { + wasPreviouslyInteracted: false, + persisted, + recordFeatureInteractionForTour: false + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + false, + 'floating_workspace_visible', + { + recordFeatureInteraction: false, + featureInteractionPersisted: persisted, + wasFeaturePreviouslyInteracted: false + } + ) + + await renderPanel(true, vi.fn(), { + wasPreviouslyInteracted: true, + persisted, + recordFeatureInteractionForTour: false + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + true, + 'floating_workspace_visible', + { + recordFeatureInteraction: false, + featureInteractionPersisted: persisted, + wasFeaturePreviouslyInteracted: true + } + ) + }) + + it('records the floating workspace tour interaction when the open snapshot deferred persistence', async () => { + await renderPanel(true, vi.fn(), { + wasPreviouslyInteracted: false, + recordFeatureInteractionForTour: true + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + true, + 'floating_workspace_visible', + { + recordFeatureInteraction: true, + featureInteractionPersisted: undefined, + wasFeaturePreviouslyInteracted: false + } + ) + }) + + it('targets the empty-state action list without co-mounting the surface fallback', async () => { + const element = await renderPanel(true) + const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') + const renderedEmptyState = ( + emptyState.type as (props: Record) => ReactElementLike + )(emptyState.props) + + expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( + 'floating-workspace-surface' + ) + expect(collectPropValues(renderedEmptyState, 'data-contextual-tour-target')).toEqual([ + 'floating-workspace-actions' + ]) + }) + + it('targets the non-empty panel surface when the action list is absent', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(true) + + expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( + 'FloatingTerminalEmptyState not found' + ) + expect(collectPropValues(element, 'data-contextual-tour-target')).toContain( + 'floating-workspace-surface' + ) + expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( + 'floating-workspace-actions' + ) + }) + it('focuses the empty floating workspace when opened for immediate shortcuts', async () => { const element = await renderPanel(true) const panel = findByProp(element, 'data-floating-terminal-panel') diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 06c5566ef84..e50a57ebe4a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -16,6 +16,7 @@ import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react' import { toast } from 'sonner' import BrowserPane from '@/components/browser-pane/BrowserPane' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane from '@/components/terminal-pane/TerminalPane' @@ -106,6 +107,13 @@ const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) type FloatingTerminalPanelProps = { open: boolean onOpenChange: (open: boolean) => void + tourInteractionSnapshot?: FloatingWorkspaceTourInteractionSnapshot | null | undefined +} + +type FloatingWorkspaceTourInteractionSnapshot = { + wasPreviouslyInteracted?: boolean + persisted?: Promise + recordFeatureInteractionForTour: boolean } const FLOATING_TERMINAL_NO_DRAG_SELECTOR = @@ -150,7 +158,8 @@ function areFloatingTerminalPanelCommittedBoundsEqual( export function FloatingTerminalPanel({ open, - onOpenChange + onOpenChange, + tourInteractionSnapshot }: FloatingTerminalPanelProps): React.JSX.Element | null { const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) @@ -342,6 +351,13 @@ export function FloatingTerminalPanel({ : activeTab?.contentType === 'terminal' ? 'terminal' : 'editor' + + useContextualTour('floating-workspace', open, 'floating_workspace_visible', { + recordFeatureInteraction: tourInteractionSnapshot?.recordFeatureInteractionForTour ?? false, + featureInteractionPersisted: tourInteractionSnapshot?.persisted, + wasFeaturePreviouslyInteracted: tourInteractionSnapshot?.wasPreviouslyInteracted + }) + const { saveDialogFileId, saveDialogFile, @@ -653,7 +669,10 @@ export function FloatingTerminalPanel({ return } createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { - title: translate("auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17", "New Browser Tab"), + title: translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17', + 'New Browser Tab' + ), focusAddressBar: true, targetGroupId: activeGroup?.id }) @@ -1346,7 +1365,12 @@ export function FloatingTerminalPanel({ /> -
+
{cwd ? tabs.map((tab) => { const isActive = tab.id === activeTerminalId @@ -1386,7 +1410,11 @@ export function FloatingTerminalPanel({ - {translate("auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24", "Loading editor...")}
+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24', + 'Loading editor...' + )} +
} > {/* Why: floating workspace markdown is scratch/local context, @@ -1416,16 +1444,25 @@ export function FloatingTerminalPanel({ ) : null} - {showOrchestrationSetup && activeTabType === "terminal" ? ( + {showOrchestrationSetup && activeTabType === 'terminal' ? (
-

{translate("auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e", "Enable orchestration")}

+

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e', + 'Enable orchestration' + )} +

- {translate("auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b", "Set up the Orca CLI and agent skill so agents can coordinate through Orca.")}

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b', + 'Set up the Orca CLI and agent skill so agents can coordinate through Orca.' + )} +

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.adc281394d', + 'Dismiss' + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f', + 'Enable' + )} +
@@ -1470,11 +1515,23 @@ export function FloatingTerminalPanel({ > - {translate("auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a", "Unsaved Changes")} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a', + 'Unsaved Changes' + )} + {saveDialogFile - ? translate("auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52", "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", { value0: saveDialogFile.relativePath.split('/').pop() }) - : translate("auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5", "This file has unsaved changes.")} + ? translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52', + '"{{value0}}" has unsaved changes. Do you want to save before closing?', + { value0: saveDialogFile.relativePath.split('/').pop() } + ) + : translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5', + 'This file has unsaved changes.' + )} @@ -1484,16 +1541,28 @@ export function FloatingTerminalPanel({ size="sm" onClick={handleFloatingSaveDialogCancel} > - {translate("auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4", "Cancel")} + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4', + 'Cancel' + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3', + "Don't Save" + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5', + 'Save' + )} + @@ -1533,7 +1602,11 @@ function FloatingTerminalEmptyState({ data-floating-terminal-shortcut-surface onPointerDown={onFocusPanel} > -
+
diff --git a/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts b/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts new file mode 100644 index 00000000000..5865056b861 --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { createFloatingWorkspaceTourInteractionSnapshot } from './floating-workspace-tour-interaction-snapshot' +import type { FeatureInteractionState } from '../../../shared/feature-interactions' + +describe('createFloatingWorkspaceTourInteractionSnapshot', () => { + it('captures first-open state before recording the floating workspace interaction', () => { + const featureInteractions: FeatureInteractionState = {} + const persisted = Promise.resolve() + const recordFeatureInteraction = vi.fn(() => { + featureInteractions['floating-workspace'] = { + firstInteractedAt: 100, + interactionCount: 1 + } + return persisted + }) + + const snapshot = createFloatingWorkspaceTourInteractionSnapshot({ + featureInteractions, + persistedUIReady: true, + recordFeatureInteraction + }) + + expect(recordFeatureInteraction).toHaveBeenCalledOnce() + expect(recordFeatureInteraction).toHaveBeenCalledWith('floating-workspace') + expect(snapshot.wasPreviouslyInteracted).toBe(false) + expect(snapshot.persisted).toBe(persisted) + expect(snapshot.recordFeatureInteractionForTour).toBe(false) + }) + + it('defers recording to the tour hook when opened before persisted UI is ready', () => { + const featureInteractions: FeatureInteractionState = {} + const recordFeatureInteraction = vi.fn(() => Promise.resolve()) + + const snapshot = createFloatingWorkspaceTourInteractionSnapshot({ + featureInteractions, + persistedUIReady: false, + recordFeatureInteraction + }) + + expect(recordFeatureInteraction).not.toHaveBeenCalled() + expect(snapshot.wasPreviouslyInteracted).toBeUndefined() + expect(snapshot.persisted).toBeUndefined() + expect(snapshot.recordFeatureInteractionForTour).toBe(true) + }) + + it('preserves returning-user state while recording the new open interaction', () => { + const persisted = Promise.resolve() + const recordFeatureInteraction = vi.fn(() => persisted) + + const snapshot = createFloatingWorkspaceTourInteractionSnapshot({ + featureInteractions: { + 'floating-workspace': { + firstInteractedAt: 100, + interactionCount: 1 + } + }, + persistedUIReady: true, + recordFeatureInteraction + }) + + expect(recordFeatureInteraction).toHaveBeenCalledWith('floating-workspace') + expect(snapshot.wasPreviouslyInteracted).toBe(true) + expect(snapshot.persisted).toBe(persisted) + expect(snapshot.recordFeatureInteractionForTour).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.ts b/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.ts new file mode 100644 index 00000000000..d2b826b9aea --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.ts @@ -0,0 +1,31 @@ +import { + hasFeatureInteraction, + type FeatureInteractionState +} from '../../../shared/feature-interactions' + +export type FloatingWorkspaceTourInteractionSnapshot = { + wasPreviouslyInteracted?: boolean + persisted?: Promise + recordFeatureInteractionForTour: boolean +} + +export function createFloatingWorkspaceTourInteractionSnapshot(args: { + featureInteractions: FeatureInteractionState + persistedUIReady: boolean + recordFeatureInteraction: (id: 'floating-workspace') => Promise +}): FloatingWorkspaceTourInteractionSnapshot { + const wasPreviouslyInteracted = hasFeatureInteraction( + args.featureInteractions, + 'floating-workspace' + ) + if (!args.persistedUIReady) { + return { + recordFeatureInteractionForTour: true + } + } + return { + wasPreviouslyInteracted, + persisted: args.recordFeatureInteraction('floating-workspace'), + recordFeatureInteractionForTour: false + } +} diff --git a/src/shared/contextual-tours.test.ts b/src/shared/contextual-tours.test.ts index 8159b4b5e2a..5c51f9d4ee7 100644 --- a/src/shared/contextual-tours.test.ts +++ b/src/shared/contextual-tours.test.ts @@ -14,6 +14,7 @@ describe('contextual tour definitions', () => { 'browser', 'tasks', 'automations', + 'floating-workspace', 'workspace-creation' ] @@ -161,6 +162,30 @@ describe('contextual tour definitions', () => { ]) }) + it('defines the floating workspace tour on the action list with a surface fallback', () => { + const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'floating-workspace') as + | ContextualTour + | undefined + + expect(tour?.steps.map((step) => step.title)).toEqual([ + 'Your portable command center', + 'Keep side work out of the way' + ]) + expect(tour?.steps.map((step) => step.body)).toEqual([ + 'Use the floating workspace for repo-root work: agents, quick commands, notes, browser tabs, and cross-cutting side work you keep with you.', + 'Park scratch terminals, references, and long-running remote sessions here without cluttering the task you’re focused on.' + ]) + expect(tour?.steps[0]).toMatchObject({ + requiredForStart: true, + preferredPlacement: 'left' + }) + expect(tour?.steps[1]?.preferredPlacement).toBe('left') + expect(tour?.steps.map((step) => step.targetSelector)).toEqual([ + '[data-contextual-tour-target="floating-workspace-actions"], [data-contextual-tour-target="floating-workspace-surface"]', + '[data-contextual-tour-target="floating-workspace-actions"], [data-contextual-tour-target="floating-workspace-surface"]' + ]) + }) + it('allows only workspace creation over its workspace composer modal', () => { const modalTours = (CONTEXTUAL_TOURS as readonly ContextualTour[]).filter( (tour) => tour.allowedActiveModals?.length diff --git a/src/shared/contextual-tours.ts b/src/shared/contextual-tours.ts index e3bf241be3b..07d0b313590 100644 --- a/src/shared/contextual-tours.ts +++ b/src/shared/contextual-tours.ts @@ -6,6 +6,7 @@ export type ContextualTourId = | 'browser' | 'tasks' | 'automations' + | 'floating-workspace' | 'workspace-creation' export type ContextualTourStepControl = { @@ -163,6 +164,26 @@ export const CONTEXTUAL_TOURS = [ } ] }, + { + id: 'floating-workspace', + steps: [ + { + title: 'Your portable command center', + body: 'Use the floating workspace for repo-root work: agents, quick commands, notes, browser tabs, and cross-cutting side work you keep with you.', + targetSelector: + '[data-contextual-tour-target="floating-workspace-actions"], [data-contextual-tour-target="floating-workspace-surface"]', + requiredForStart: true, + preferredPlacement: 'left' + }, + { + title: 'Keep side work out of the way', + body: 'Park scratch terminals, references, and long-running remote sessions here without cluttering the task you’re focused on.', + targetSelector: + '[data-contextual-tour-target="floating-workspace-actions"], [data-contextual-tour-target="floating-workspace-surface"]', + preferredPlacement: 'left' + } + ] + }, { id: 'workspace-creation', allowedActiveModals: ['new-workspace-composer'], diff --git a/src/shared/feature-education-telemetry.test.ts b/src/shared/feature-education-telemetry.test.ts index 1be574155f9..7b1f8732e25 100644 --- a/src/shared/feature-education-telemetry.test.ts +++ b/src/shared/feature-education-telemetry.test.ts @@ -16,6 +16,9 @@ describe('feature education telemetry constants', () => { expect(normalizeFeatureEducationSource('workspace_agent_sessions_visible')).toBe( 'workspace_agent_sessions_visible' ) + expect(normalizeFeatureEducationSource('floating_workspace_visible')).toBe( + 'floating_workspace_visible' + ) expect(normalizeFeatureEducationSource('setup_guide_parallel_work')).toBe( 'setup_guide_parallel_work' ) diff --git a/src/shared/feature-education-telemetry.ts b/src/shared/feature-education-telemetry.ts index ea0a32ee614..4e620a97ba8 100644 --- a/src/shared/feature-education-telemetry.ts +++ b/src/shared/feature-education-telemetry.ts @@ -6,6 +6,7 @@ export const FEATURE_EDUCATION_CONTEXTUAL_TOUR_IDS = [ 'browser', 'tasks', 'automations', + 'floating-workspace', 'workspace-creation' ] as const satisfies readonly ContextualTourId[] @@ -15,6 +16,7 @@ export const FEATURE_EDUCATION_SOURCES = [ 'browser_visible', 'tasks_open', 'automations_open', + 'floating_workspace_visible', 'workspace_creation_visible', 'workspace_creation_modal', 'setup_guide_parallel_work',