mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
Add floating workspace contextual tour
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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?"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user