From 64704daac5af1f93ea7aa1318b4fbdc852e46b85 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:14:15 -0400 Subject: [PATCH 001/274] feat(feature-tips): one-time tip for agent session search (#22923) * feat(feature-tips): one-time tip for agent session search Session search is only discoverable from Settings. Add a feature tip that existing users see once, which turns search on, shows the first index build's progress in place, and opens the sidebar search once it is ready. A toast says when a build left running in the background finishes. Also share the two-column tip layout across the voice, Cmd+J and session search dialogs, and move the CLI tip dialog into its own file. * refactor(feature-tips): simplify the session search tip after review - Watch for a background finish only when this tip closes mid-index; closing any other tip no longer arms a stray "ready" toast. - The hook detects the close itself, so dialogClosed/reset and the onStatus callback on useSessionSearchStatus are gone. - Table-driven dialog copy, reuse FeatureTipActions, and share the eyebrow badge and settings link across the voice, Cmd+J and session search tips. - One getPendingFeatureTips for the startup gate and the modal. - Demo: a phase timing table and hoisted header props. - e2e helpers mark the new tip seen too. * fix(feature-tips): retire the session search tip once the user has switched search Turning session search on or off in Settings, or enabling it from the sidebar, now marks the tip seen, the way the Voice switch does, so a user who turned search on and later off is not pitched it again. * test(ai-vault): give the legacy-filter store mock markFeatureTipsSeen Also mark the tip seen only after the sidebar's enable actually saves. --- .../startup/dev-education-suppression.test.ts | 7 +- .../use-onboarding-and-feature-tips.ts | 9 +- .../feature-tips/CliSetupTipDialog.tsx | 152 +++++++++++ .../feature-tips/CmdJPaletteTipDialog.tsx | 142 +++++------ .../feature-tips/FeatureTipActions.tsx | 10 +- .../feature-tips/FeatureTipDialogFrame.tsx | 71 ++++++ .../feature-tips/FeatureTipsModal.tsx | 184 ++++---------- .../SessionSearchFeatureTipVisual.tsx | 236 ++++++++++++++++++ .../SessionSearchTipDialog.test.tsx | 91 +++++++ .../feature-tips/SessionSearchTipDialog.tsx | 161 ++++++++++++ .../feature-tips/VoiceDictationTipDialog.tsx | 168 ++++++------- .../feature-tip-modal-state.test.ts | 30 ++- .../feature-tips/feature-tip-modal-state.ts | 18 +- .../feature-tip-startup-gate.test.ts | 102 ++++++-- .../feature-tips/feature-tip-startup-gate.ts | 58 ++++- .../session-search-feature-tip-demo.ts | 144 +++++++++++ .../use-session-search-tip-setup.test.tsx | 172 +++++++++++++ .../use-session-search-tip-setup.ts | 94 +++++++ .../AiVaultPanel.legacy-filter.test.tsx | 2 +- .../right-sidebar/AiVaultPanelSearch.test.tsx | 23 +- .../right-sidebar/AiVaultPanelSearch.tsx | 1 + .../SessionHistorySettingsPane.test.tsx | 8 +- .../settings/SessionHistorySettingsPane.tsx | 5 + .../session-history-status-copy.test.ts | 43 ++++ .../settings/session-history-status-copy.ts | 8 + src/renderer/src/i18n/locales/en.json | 46 ++++ src/shared/feature-tips.test.ts | 21 +- src/shared/feature-tips.ts | 24 +- .../e2e/terminal-column-desync-repro.spec.ts | 4 +- ...terminal-long-table-scroll-restore.spec.ts | 25 +- ...l-pinned-viewport-streaming-switch.spec.ts | 4 +- ...al-pinned-viewport-worktree-switch.spec.ts | 4 +- ...nal-raw-emoji-table-scroll-restore.spec.ts | 4 +- ...inal-reveal-stale-pty-resize-repro.spec.ts | 4 +- ...erminal-streaming-refocus-viewport.spec.ts | 4 +- 35 files changed, 1694 insertions(+), 385 deletions(-) create mode 100644 src/renderer/src/components/feature-tips/CliSetupTipDialog.tsx create mode 100644 src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx create mode 100644 src/renderer/src/components/feature-tips/SessionSearchFeatureTipVisual.tsx create mode 100644 src/renderer/src/components/feature-tips/SessionSearchTipDialog.test.tsx create mode 100644 src/renderer/src/components/feature-tips/SessionSearchTipDialog.tsx create mode 100644 src/renderer/src/components/feature-tips/session-search-feature-tip-demo.ts create mode 100644 src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx create mode 100644 src/renderer/src/components/feature-tips/use-session-search-tip-setup.ts create mode 100644 src/renderer/src/components/settings/session-history-status-copy.test.ts diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts index e84541f0f68e..534c64b3aa27 100644 --- a/src/main/startup/dev-education-suppression.test.ts +++ b/src/main/startup/dev-education-suppression.test.ts @@ -133,7 +133,12 @@ describe('suppressDevEducationForStore', () => { outcome: 'dismissed', lastCompletedStep: 1 }) - expect(state.ui.featureTipsSeenIds).toEqual(['voice-dictation', 'orca-cli', 'cmd-j-palette']) + expect(state.ui.featureTipsSeenIds).toEqual([ + 'voice-dictation', + 'agent-session-search', + 'orca-cli', + 'cmd-j-palette' + ]) expect(state.ui.contextualToursSeenIds).toEqual([ 'tasks', ...CONTEXTUAL_TOUR_IDS.filter((id) => id !== 'tasks') diff --git a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts index dc689fe15651..a170bcce16f7 100644 --- a/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts +++ b/src/renderer/src/app-shell/use-onboarding-and-feature-tips.ts @@ -11,6 +11,7 @@ import { trackOrcaCliFeatureTipShown } from '../components/feature-tips/feature-tip-telemetry' import { useAppStore } from '../store' +import { isWebClientLocation } from '../lib/web-client-location' import type { OnboardingState } from '../../../shared/onboarding-state-types' export type OnboardingGate = ReturnType @@ -99,7 +100,8 @@ export function useOnboardingAndFeatureTips() { persistedUIReady, promptedThisSession: promptedThisSessionRef.current, settings, - suppressedByOnboardingThisSession: suppressedByOnboardingThisSessionRef.current + suppressedByOnboardingThisSession: suppressedByOnboardingThisSessionRef.current, + webClient: isWebClientLocation() }) if (featureTipsDecision.kind === 'suppress-for-onboarding') { @@ -120,7 +122,10 @@ export function useOnboardingAndFeatureTips() { } // Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch. actions.markFeatureTipsSeen([featureTipsDecision.tipId]) - actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId }) + actions.openModal('feature-tips', { + source: 'app_open', + tipId: featureTipsDecision.tipId + }) }, [ activeModal, actions, diff --git a/src/renderer/src/components/feature-tips/CliSetupTipDialog.tsx b/src/renderer/src/components/feature-tips/CliSetupTipDialog.tsx new file mode 100644 index 000000000000..62404ff0ee0f --- /dev/null +++ b/src/renderer/src/components/feature-tips/CliSetupTipDialog.tsx @@ -0,0 +1,152 @@ +import type { JSX } from 'react' +import type { FeatureTip } from '../../../../shared/feature-tips' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { CliFeatureTipVisual } from './CliFeatureTipVisual' +import { CliSkillSetupTerminal } from './CliSkillSetupTerminal' +import { FeatureTipActions } from './FeatureTipActions' + +function WorktreePromptTerm({ children }: { children: string }): JSX.Element { + return ( + + {children} + + ) +} + +export function CliSetupTipDialog({ + open, + tip, + primaryBusy, + skillTerminalOpen, + onOpenChange, + onPrimaryAction, + onSkip +}: { + open: boolean + tip: FeatureTip + primaryBusy: boolean + skillTerminalOpen: boolean + onOpenChange: (open: boolean) => void + onPrimaryAction: () => void + onSkip: () => void +}): JSX.Element { + return ( + + {/* Why: the CLI tip sits over terminal surfaces, so it needs a local token-mixed surface. */} + +
+ +
+ + {tip.title} + + + {tip.description} + +
+

+ {translate( + 'auto.components.feature.tips.FeatureTipsModal.4795ac2d4a', + 'Try asking:' + )} +

+

+ {translate( + 'auto.components.feature.tips.FeatureTipsModal.55846c7f95', + '“Split this PR into two' + )} + + {translate( + 'auto.components.feature.tips.FeatureTipsModal.27c567a89c', + 'worktrees' + )} + {' '} + {translate( + 'auto.components.feature.tips.FeatureTipsModal.7fc6f02099', + 'and create PRs for each.”' + )} +

+

+ {translate( + 'auto.components.feature.tips.FeatureTipsModal.864e2db28f', + '“When the agent in' + )} + + {translate( + 'auto.components.feature.tips.FeatureTipsModal.298301b7a0', + 'worktree' + )} + {' '} + {translate( + 'auto.components.feature.tips.FeatureTipsModal.3c6c478462', + 'X finishes, send it the review task.”' + )} +

+
+
+ {skillTerminalOpen ? : null} +
+ + + {skillTerminalOpen ? ( + + ) : ( + + )} + +
+
+
+ {skillTerminalOpen ? null : } +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.tsx b/src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.tsx index 427bb947ff0e..e7ee26b8594f 100644 --- a/src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.tsx +++ b/src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.tsx @@ -1,17 +1,14 @@ import type { JSX } from 'react' import type { FeatureTip } from '../../../../shared/feature-tips' -import { Badge } from '@/components/ui/badge' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { formatShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual' import { FeatureTipActions } from './FeatureTipActions' +import { + FeatureTipDialogFrame, + FeatureTipEyebrow, + FeatureTipSettingsLine +} from './FeatureTipDialogFrame' import { translate } from '@/i18n/i18n' export function CmdJPaletteTipDialog({ @@ -49,80 +46,61 @@ export function CmdJPaletteTipDialog({ // CLI tip for a consistent "feature education" presentation; keeps the palette demo // prominent on the right. return ( - - event.preventDefault()} - > -
- -
- {/* Why: uppercase eyebrow reads as a category label, not a feature launch. */} - - {tip.eyebrow.toUpperCase()} - - {/* Why: flow the shortcut chip as inline text (not a flex item) so the - short Mac label (⌘⇧J) stays on one line, while a wide label like - "Ctrl+Shift+J" wraps to the next line only when it doesn't fit — - instead of being pushed to the right edge on Win/Linux. */} - - {titlePrefix.trimEnd()} - {displayShortcutLabel ? ( - <> - {' '} - - {displayShortcutLabel} - - - ) : null} - {titleSuffix ? ` ${titleSuffix}` : null} - - - {tip.description} - - {translate( - 'auto.components.feature.tips.CmdJPaletteTipDialog.8241897205', - 'Rebind the shortcut anytime in' - )}{' '} - - . - - -
-
- - - event.preventDefault()} + visual={} + > + +
+ {/* Why: uppercase eyebrow reads as a category label, not a feature launch. */} + + {/* Why: flow the shortcut chip as inline text (not a flex item) so the + short Mac label (⌘⇧J) stays on one line, while a wide label like + "Ctrl+Shift+J" wraps to the next line only when it doesn't fit — + instead of being pushed to the right edge on Win/Linux. */} + + {titlePrefix.trimEnd()} + {displayShortcutLabel ? ( + <> + {' '} + + {displayShortcutLabel} + + + ) : null} + {titleSuffix ? ` ${titleSuffix}` : null} + + + {tip.description} + - +
-
-
- -
-
- -
+ + + + + + ) } diff --git a/src/renderer/src/components/feature-tips/FeatureTipActions.tsx b/src/renderer/src/components/feature-tips/FeatureTipActions.tsx index f12f2613405d..358e6a17e9cf 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipActions.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipActions.tsx @@ -8,6 +8,9 @@ function getPrimaryBusyLabel(action: FeatureTipAction): string { if (action === 'setup-cli') { return 'Installing...' } + if (action === 'enable-session-search') { + return 'Turning on...' + } return 'Working...' } @@ -18,7 +21,8 @@ export function FeatureTipActions({ onSkip, showSkip = true, fullWidth = false, - primaryButtonRef + primaryButtonRef, + label = currentTip.ctaLabel }: { currentTip: FeatureTip primaryBusy: boolean @@ -27,6 +31,8 @@ export function FeatureTipActions({ showSkip?: boolean fullWidth?: boolean primaryButtonRef?: Ref + /** Overrides the tip's CTA for tips whose button changes with their state. */ + label?: string }): JSX.Element { return ( <> @@ -47,7 +53,7 @@ export function FeatureTipActions({ {getPrimaryBusyLabel(currentTip.action)} ) : ( - currentTip.ctaLabel + label )} diff --git a/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx b/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx new file mode 100644 index 000000000000..6891cca9e612 --- /dev/null +++ b/src/renderer/src/components/feature-tips/FeatureTipDialogFrame.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps, JSX, ReactNode } from 'react' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent } from '@/components/ui/dialog' + +/** Shared two-column tip layout: copy and actions on the left, a feature visual on the right. */ +export function FeatureTipDialogFrame({ + open, + onOpenChange, + onOpenAutoFocus, + visual, + children +}: { + open: boolean + onOpenChange: (open: boolean) => void + onOpenAutoFocus: ComponentProps['onOpenAutoFocus'] + visual: ReactNode + children: ReactNode +}): JSX.Element { + return ( + + +
+ {children} +
+
+
{visual}
+
+
+
+ ) +} + +export function FeatureTipEyebrow({ label }: { label: string }): JSX.Element { + return ( + + {label.toUpperCase()} + + ) +} + +/** A muted "Change it anytime in Settings → X." line whose link leaves the tip for Settings. */ +export function FeatureTipSettingsLine({ + lead, + link, + onClick +}: { + lead: string + link: string + onClick: () => void +}): JSX.Element { + return ( + + {lead}{' '} + + . + + ) +} diff --git a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx index 336f2afeee82..631c121cc154 100644 --- a/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx +++ b/src/renderer/src/components/feature-tips/FeatureTipsModal.tsx @@ -6,20 +6,9 @@ import { ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' -import { CliFeatureTipVisual } from './CliFeatureTipVisual' +import { CliSetupTipDialog } from './CliSetupTipDialog' import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog' -import { CliSkillSetupTerminal } from './CliSkillSetupTerminal' -import { FeatureTipActions } from './FeatureTipActions' import { installCliFromFeatureTip } from './feature-tip-cli-install-action' import { getFeatureTipForModal } from './feature-tip-modal-state' import { @@ -29,17 +18,12 @@ import { trackOrcaCliFeatureTipSetupResult } from './feature-tip-telemetry' import { useMountedRef } from '@/hooks/useMountedRef' +import { isWebClientLocation } from '@/lib/web-client-location' import { translate } from '@/i18n/i18n' +import { SessionSearchTipDialog } from './SessionSearchTipDialog' +import { useSessionSearchTipSetup } from './use-session-search-tip-setup' import { VoiceDictationTipDialog } from './VoiceDictationTipDialog' -function WorktreePromptTerm({ children }: { children: string }): JSX.Element { - return ( - - {children} - - ) -} - export default function FeatureTipsModal(): JSX.Element | null { const activeModal = useAppStore((s) => s.activeModal) const closeModal = useAppStore((s) => s.closeModal) @@ -51,6 +35,7 @@ export default function FeatureTipsModal(): JSX.Element | null { const featureInteractions = useAppStore((s) => s.featureInteractions) const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen) const modalData = useAppStore((s) => s.modalData) + const showAiVaultSearch = useAppStore((s) => s.showAiVaultSearch) const mountedRef = useMountedRef() const activeModalRef = useRef(activeModal) const setupRequestIdRef = useRef(0) @@ -62,7 +47,11 @@ export default function FeatureTipsModal(): JSX.Element | null { modalData, seenTipIds, featureInteractions, - settings + settings, + webClient: isWebClientLocation() + }) + const sessionSearchSetup = useSessionSearchTipSetup({ + dialogOpen: isOpen && currentTip?.id === 'agent-session-search' }) useEffect(() => { @@ -115,6 +104,13 @@ export default function FeatureTipsModal(): JSX.Element | null { openSettingsPage() } + const openSessionSearchSettings = (): void => { + markCurrentTipSeen() + closeModal() + openSettingsTarget({ pane: 'session-history', repoId: null }) + openSettingsPage() + } + const enableOrchestrationSkillSetup = (): void => { localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, '1') localStorage.removeItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY) @@ -150,6 +146,20 @@ export default function FeatureTipsModal(): JSX.Element | null { openSettingsPage() break } + case 'enable-session-search': { + if (sessionSearchSetup.stage === 'offer') { + // Why: stay open through the first index so search is never offered half-built. + setPrimaryBusy(true) + await sessionSearchSetup.enable() + setPrimaryBusy(false) + break + } + closeModal() + if (sessionSearchSetup.stage === 'ready') { + showAiVaultSearch() + } + break + } case 'setup-cli': { const setupRequestId = setupRequestIdRef.current + 1 setupRequestIdRef.current = setupRequestId @@ -240,114 +250,15 @@ export default function FeatureTipsModal(): JSX.Element | null { if (currentTip.action === 'setup-cli') { return ( - - {/* Why: the CLI tip sits over terminal surfaces, so it needs a local token-mixed surface. */} - -
- -
- - {currentTip.title} - - - {currentTip.description} - -
-

- {translate( - 'auto.components.feature.tips.FeatureTipsModal.4795ac2d4a', - 'Try asking:' - )} -

-

- {translate( - 'auto.components.feature.tips.FeatureTipsModal.55846c7f95', - '“Split this PR into two' - )} - - {translate( - 'auto.components.feature.tips.FeatureTipsModal.27c567a89c', - 'worktrees' - )} - {' '} - {translate( - 'auto.components.feature.tips.FeatureTipsModal.7fc6f02099', - 'and create PRs for each.”' - )} -

-

- {translate( - 'auto.components.feature.tips.FeatureTipsModal.864e2db28f', - '“When the agent in' - )} - - {translate( - 'auto.components.feature.tips.FeatureTipsModal.298301b7a0', - 'worktree' - )} - {' '} - {translate( - 'auto.components.feature.tips.FeatureTipsModal.3c6c478462', - 'X finishes, send it the review task.”' - )} -

-
-
- {skillTerminalOpen ? : null} -
- - - {skillTerminalOpen ? ( - - ) : ( - void handlePrimaryAction()} - onSkip={handleSkip} - showSkip={false} - fullWidth - /> - )} - -
-
-
- {skillTerminalOpen ? null : } -
-
-
-
+ void handlePrimaryAction()} + onSkip={handleSkip} + /> ) } @@ -365,6 +276,21 @@ export default function FeatureTipsModal(): JSX.Element | null { ) } + if (currentTip.action === 'enable-session-search') { + return ( + void handlePrimaryAction()} + onSettingsClick={openSessionSearchSettings} + stage={sessionSearchSetup.stage} + status={sessionSearchSetup.status} + /> + ) + } + if (currentTip.action !== 'enable-voice') { currentTip.action satisfies never return null diff --git a/src/renderer/src/components/feature-tips/SessionSearchFeatureTipVisual.tsx b/src/renderer/src/components/feature-tips/SessionSearchFeatureTipVisual.tsx new file mode 100644 index 000000000000..78f3dc393abc --- /dev/null +++ b/src/renderer/src/components/feature-tips/SessionSearchFeatureTipVisual.tsx @@ -0,0 +1,236 @@ +import { useEffect, useState, type JSX, type ReactNode } from 'react' +import { Files, GitBranch, ListChecks, PanelRight } from 'lucide-react' +import { AI_VAULT_AGENT_LABELS, AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { + getLocalExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' +import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers' +import { AgentSessionHistoryIcon } from '@/components/right-sidebar/agent-session-history-icon' +import { AiVaultPanelHeader } from '@/components/right-sidebar/AiVaultPanelHeader' +import { highlightedSearchSnippet } from '@/components/right-sidebar/AiVaultSearchEvidence' +import { DEFAULT_AI_VAULT_SESSION_LIMIT } from '@/components/right-sidebar/ai-vault-session-limit' +import { + DEFAULT_AI_VAULT_GROUP, + DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS +} from '@/components/right-sidebar/ai-vault-view-defaults' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { + getSessionSearchDemoRecentRows, + getSessionSearchDemoScenes, + type SessionSearchDemoRow +} from './session-search-feature-tip-demo' + +type DemoPhase = 'idle' | 'typing' | 'searching' | 'results' | 'focused' | 'erasing' +type DemoState = { scene: number; phase: DemoPhase; chars: number } + +// Per-step delay; typing and erasing advance one character per step. +const PHASE_MS: Record = { + idle: 1100, + typing: 65, + searching: 550, + results: 1300, + focused: 2200, + erasing: 24 +} +const ROW_STAGGER_MS = 90 + +function noop(): void {} + +// The real panel header, inert, with everything but the query held still. +const DEMO_HEADER_PROPS = { + hasScanResult: true, + activeWorktreePath: '/demo', + activeProjectKey: 'demo', + scope: 'all', + executionHostScope: LOCAL_EXECUTION_HOST_ID, + hostScopeOptions: [{ id: LOCAL_EXECUTION_HOST_ID, label: getLocalExecutionHostLabel() }], + agents: AI_VAULT_AGENTS, + group: DEFAULT_AI_VAULT_GROUP, + hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS, + sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT, + adjustmentCount: 0, + onQueryChange: noop, + onScopeChange: noop, + onExecutionHostScopeChange: noop, + onAgentEnabledChange: noop, + onAllAgentsEnabledChange: noop, + onGroupChange: noop, + onHideEmptySessionsChange: noop, + onSessionLimitChange: noop, + onReset: noop, + onRefresh: noop +} as const + +function nextDemoState(state: DemoState, queryLength: number, sceneCount: number): DemoState { + switch (state.phase) { + case 'idle': + return { ...state, phase: 'typing', chars: 0 } + case 'typing': + return state.chars < queryLength + ? { ...state, chars: state.chars + 1 } + : { ...state, phase: 'searching' } + case 'searching': + return { ...state, phase: 'results' } + case 'results': + return { ...state, phase: 'focused' } + case 'focused': + return { ...state, phase: 'erasing' } + case 'erasing': + return state.chars > 0 + ? { ...state, chars: state.chars - 1 } + : { scene: (state.scene + 1) % sceneCount, phase: 'idle', chars: 0 } + } +} + +function DemoActivityTab({ + active = false, + children +}: { + active?: boolean + children: ReactNode +}): JSX.Element { + return ( + + {children} + + ) +} + +// Mirrors the right sidebar's tab row so the tip shows where the panel lives. +function DemoActivityStrip(): JSX.Element { + return ( +
+ + + + + + + + + + + + + + + + +
+ ) +} + +function DemoSessionRow({ + row, + isHit, + focused, + index +}: { + row: SessionSearchDemoRow + isHit: boolean + focused: boolean + index: number +}): JSX.Element { + return ( +
+
+ {row.title} +
+
+ {row.role} + : {isHit ? highlightedSearchSnippet(row.text) : row.text} +
+
+ + {AI_VAULT_AGENT_LABELS[row.agent]} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: row.messages } + )} + + · + {row.age} +
+
+ ) +} + +export function SessionSearchFeatureTipVisual(): JSX.Element { + const reducedMotion = usePrefersReducedMotion() + const scenes = getSessionSearchDemoScenes() + const recentRows = getSessionSearchDemoRecentRows() + const [state, setState] = useState({ scene: 0, phase: 'idle', chars: 0 }) + // Why: reduced motion freezes on the first answered search instead of looping. + const shown: DemoState = reducedMotion + ? { scene: 0, phase: 'results', chars: scenes[0].query.length } + : state + const scene = scenes[shown.scene] + const queryLength = scene.query.length + + useEffect(() => { + if (reducedMotion) { + return + } + const timeoutId = window.setTimeout( + () => setState((current) => nextDemoState(current, queryLength, scenes.length)), + // Why: a beat after the last keystroke before the search starts. + state.phase === 'typing' && state.chars === queryLength + ? PHASE_MS.typing * 2 + : PHASE_MS[state.phase] + ) + return () => window.clearTimeout(timeoutId) + }, [queryLength, reducedMotion, scenes.length, state]) + + const showingHits = + shown.phase === 'results' || shown.phase === 'focused' || shown.phase === 'erasing' + const rows = showingHits ? scene.hits : recentRows + const dimmed = + shown.phase === 'typing' || shown.phase === 'searching' || shown.phase === 'erasing' + + return ( +