Add floating workspace contextual tour

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-09 17:30:25 -07:00
co-authored by Orca
parent 9369708f6b
commit 6d9ec1ad9d
15 changed files with 647 additions and 40 deletions
@@ -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?"
+10 -1
View File
@@ -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<void>
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 {
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
tourInteractionSnapshot={floatingWorkspaceTourInteractionSnapshotRef.current}
/>
</RecoverableRenderErrorBoundary>
) : null}
@@ -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('[', {
@@ -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)
})
})
@@ -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<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
@@ -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)
})
})
@@ -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<ContextualTourId, string>
export type UseContextualTourOptions = {
recordFeatureInteraction?: boolean | undefined
featureInteractionPersisted?: Promise<void> | undefined
wasFeaturePreviouslyInteracted?: boolean | undefined
}
export function createContextualTourInteractionSnapshot(args: {
id: ContextualTourId
featureInteractions: FeatureInteractionState
recordFeatureInteraction: (id: ContextualTourId) => Promise<void>
recordFeatureInteractionForTour: boolean
featureInteractionPersisted?: Promise<void> | undefined
wasFeaturePreviouslyInteracted?: boolean | undefined
}): { persisted: Promise<void>; 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<void>
@@ -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
@@ -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<void> {
await Promise.resolve()
}
async function renderPanel(open: boolean, onOpenChange = vi.fn()): Promise<unknown> {
async function renderPanel(
open: boolean,
onOpenChange = vi.fn(),
tourInteractionSnapshot?: {
wasPreviouslyInteracted: boolean
persisted?: Promise<void>
recordFeatureInteractionForTour: boolean
} | null
): Promise<unknown> {
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<string, unknown>) => 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')
@@ -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<void>
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({
/>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
<div
className="relative min-h-0 flex-1 overflow-hidden bg-background"
data-contextual-tour-target={
hasVisibleFloatingTabs ? 'floating-workspace-surface' : undefined
}
>
{cwd
? tabs.map((tab) => {
const isActive = tab.id === activeTerminalId
@@ -1386,7 +1410,11 @@ export function FloatingTerminalPanel({
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate("auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24", "Loading editor...")}</div>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24',
'Loading editor...'
)}
</div>
}
>
{/* Why: floating workspace markdown is scratch/local context,
@@ -1416,16 +1444,25 @@ export function FloatingTerminalPanel({
) : null}
</div>
</div>
{showOrchestrationSetup && activeTabType === "terminal" ? (
{showOrchestrationSetup && activeTabType === 'terminal' ? (
<div
className="absolute right-4 bottom-4 z-10 w-[280px] rounded-md border border-border/60 bg-card/95 p-3 text-card-foreground shadow-xs"
data-floating-terminal-no-drag
>
<div className="space-y-2">
<div className="space-y-0.5">
<p className="text-sm font-medium">{translate("auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e", "Enable orchestration")}</p>
<p className="text-sm font-medium">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e',
'Enable orchestration'
)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate("auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b", "Set up the Orca CLI and agent skill so agents can coordinate through Orca.")}</p>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b',
'Set up the Orca CLI and agent skill so agents can coordinate through Orca.'
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
@@ -1435,7 +1472,11 @@ export function FloatingTerminalPanel({
className="flex-1"
onClick={dismissOrchestrationSetup}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.adc281394d", "Dismiss")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.adc281394d',
'Dismiss'
)}
</Button>
<Button
type="button"
variant="default"
@@ -1443,7 +1484,11 @@ export function FloatingTerminalPanel({
className="flex-1"
onClick={() => setOrchestrationDialogOpen(true)}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f", "Enable")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f',
'Enable'
)}
</Button>
</div>
</div>
</div>
@@ -1470,11 +1515,23 @@ export function FloatingTerminalPanel({
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">{translate("auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a", "Unsaved Changes")}</DialogTitle>
<DialogTitle className="text-sm">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a',
'Unsaved Changes'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{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.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
@@ -1484,16 +1541,28 @@ export function FloatingTerminalPanel({
size="sm"
onClick={handleFloatingSaveDialogCancel}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4", "Cancel")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4',
'Cancel'
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFloatingSaveDialogDiscard}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3", "Don't Save")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3',
"Don't Save"
)}
</Button>
<Button type="button" size="sm" onClick={handleFloatingSaveDialogSave}>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5", "Save")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5',
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1533,7 +1602,11 @@ function FloatingTerminalEmptyState({
data-floating-terminal-shortcut-surface
onPointerDown={onFocusPanel}
>
<div className="flex w-[360px] flex-col items-center gap-1.5" data-floating-terminal-no-drag>
<div
className="flex w-[360px] flex-col items-center gap-1.5"
data-floating-terminal-no-drag
data-contextual-tour-target="floating-workspace-actions"
>
<Button
type="button"
variant="ghost"
@@ -1541,7 +1614,12 @@ function FloatingTerminalEmptyState({
onClick={onNewTerminal}
>
<TerminalSquare className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9", "New Terminal")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9',
'New Terminal'
)}
</span>
<FloatingEmptyStateShortcut keys={newTerminalShortcutKeys} />
</Button>
<Button
@@ -1551,7 +1629,12 @@ function FloatingTerminalEmptyState({
onClick={onNewMarkdown}
>
<FileText className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.629528690b", "New Markdown Note")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.629528690b',
'New Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={newMarkdownShortcutKeys} />
</Button>
<Button
@@ -1561,7 +1644,12 @@ function FloatingTerminalEmptyState({
onClick={onOpenMarkdown}
>
<FileText className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5", "Open Markdown Note")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5',
'Open Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={openMarkdownShortcutKeys} />
</Button>
<Button
@@ -1571,7 +1659,12 @@ function FloatingTerminalEmptyState({
onClick={onNewBrowser}
>
<Globe className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.8b07759314", "New Browser")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.8b07759314',
'New Browser'
)}
</span>
<FloatingEmptyStateShortcut keys={newBrowserShortcutKeys} />
</Button>
<Button
@@ -1581,7 +1674,12 @@ function FloatingTerminalEmptyState({
onClick={onClose}
>
<Minus className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b", "Minimize")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b',
'Minimize'
)}
</span>
<FloatingEmptyStateShortcut keys={closeShortcutKeys} />
</Button>
</div>
@@ -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)
})
})
@@ -0,0 +1,31 @@
import {
hasFeatureInteraction,
type FeatureInteractionState
} from '../../../shared/feature-interactions'
export type FloatingWorkspaceTourInteractionSnapshot = {
wasPreviouslyInteracted?: boolean
persisted?: Promise<void>
recordFeatureInteractionForTour: boolean
}
export function createFloatingWorkspaceTourInteractionSnapshot(args: {
featureInteractions: FeatureInteractionState
persistedUIReady: boolean
recordFeatureInteraction: (id: 'floating-workspace') => Promise<void>
}): FloatingWorkspaceTourInteractionSnapshot {
const wasPreviouslyInteracted = hasFeatureInteraction(
args.featureInteractions,
'floating-workspace'
)
if (!args.persistedUIReady) {
return {
recordFeatureInteractionForTour: true
}
}
return {
wasPreviouslyInteracted,
persisted: args.recordFeatureInteraction('floating-workspace'),
recordFeatureInteractionForTour: false
}
}
+25
View File
@@ -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
+21
View File
@@ -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'],
@@ -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'
)
@@ -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',