From fa7560958ded1ae6b0c1343c1266f6cf0ca373e7 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:03:53 +0800 Subject: [PATCH] refactor(ui): simplify navigation and session entry --- docs/ui-interaction-and-motion.md | 74 +++++++- ui/src/App.tsx | 5 +- .../ActivityBar.current-state.spec.tsx | 56 ++++-- .../ActivityBar.drawer-state.spec.tsx | 21 ++- ui/src/components/ActivityBar.spec.ts | 41 +++-- ui/src/components/ActivityBar.tsx | 42 +++-- .../ActivityBarUtilityMenu.spec.tsx | 34 +++- ui/src/components/ActivityBarUtilityMenu.tsx | 48 ++++- ui/src/components/activity-navigation.ts | 15 +- .../ChatWorkspaceSection.auto-quant.spec.tsx | 107 ++++++++++- .../workspace/ChatWorkspaceSection.spec.tsx | 51 +++++- .../workspace/ChatWorkspaceSection.tsx | 171 +++++++++++++----- .../workspace/HarnessNavigationGroup.tsx | 50 +++++ .../workspace/HarnessWorkspaceEntry.tsx | 38 ++++ .../components/workspace/ResumeCta.spec.tsx | 25 ++- ui/src/components/workspace/ResumeCta.tsx | 20 +- ui/src/components/workspace/Sidebar.spec.tsx | 52 +++++- ui/src/components/workspace/Sidebar.tsx | 33 +++- ui/src/components/workspace/Terminal.tsx | 11 +- .../workspace/WorkspaceView.spec.tsx | 1 - ui/src/components/workspace/WorkspaceView.tsx | 1 - .../__tests__/terminalLayout.spec.ts | 5 +- ui/src/components/workspace/workspaces.css | 130 +++++++------ ui/src/hooks/useActivityRailState.spec.ts | 30 ++- ui/src/hooks/useActivityRailState.ts | 17 +- ui/src/i18n/locales/en.ts | 24 ++- ui/src/i18n/locales/ja.ts | 20 +- ui/src/i18n/locales/zh-Hant.ts | 16 +- ui/src/i18n/locales/zh.ts | 16 +- ui/src/index.css | 24 +++ ui/src/live/ui-layout.spec.ts | 29 ++- ui/src/live/ui-layout.ts | 13 +- ui/src/pages/ActivityBarSettingsPage.spec.tsx | 2 +- ui/src/pages/ChatLandingPage.tsx | 31 +++- ui/src/pages/ChatPageShell.spec.tsx | 12 +- ui/src/pages/ChatPageShell.tsx | 65 +------ ui/src/pages/QuickStartPage.spec.tsx | 39 ++++ ui/src/pages/QuickStartPage.tsx | 50 +++++ ui/src/pages/WorkspaceManagerPage.spec.tsx | 4 - ui/src/pages/WorkspaceManagerPage.tsx | 1 - ui/src/tabs/UrlAdopter.spec.tsx | 12 ++ ui/src/tabs/UrlAdopter.tsx | 11 +- ui/src/tabs/registry.spec.ts | 5 + ui/src/tabs/registry.tsx | 4 +- ui/src/tabs/types.ts | 2 + 45 files changed, 1083 insertions(+), 375 deletions(-) create mode 100644 ui/src/components/workspace/HarnessNavigationGroup.tsx create mode 100644 ui/src/components/workspace/HarnessWorkspaceEntry.tsx create mode 100644 ui/src/pages/QuickStartPage.spec.tsx create mode 100644 ui/src/pages/QuickStartPage.tsx diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md index 0fe7c06be..a6be32e6f 100644 --- a/docs/ui-interaction-and-motion.md +++ b/docs/ui-interaction-and-motion.md @@ -41,9 +41,53 @@ The stable page hierarchy is: 3. one focused working view; 4. dialogs, drawers, and popovers for temporary decisions. -The activity rail's items, groups, and visibility are user-arranged from -Settings → Activity bar and stored in `data/ui-layout.json`. The rail only -renders the joined result. Deep links to a hidden surface still adopt. +The activity rail's utility items, groups, and visibility are user-arranged from +Settings → Activity bar and stored in `data/ui-layout.json`. The three Harnesses +are a fixed work section below those utilities; their visibility follows the +same saved entry settings. Deep links to a hidden surface still adopt. +The former Beta navigation group is flattened into the primary list, including +in the layout editor. Saved Beta items retain their order after primary items; +custom groups, hidden entries, and feature gates remain unchanged. Beta feature +availability is independent of navigation grouping. + +Quick Start (`/quick-start`) is the default general landing shortcut. Its Harness +selector reuses Chat, Auto Quant, and Auto Prediction landing/setup flows and +keeps per-Harness drafts while switching. It owns no Workspace or Session history. +The saved primary `chat` layout slot now labels this shortcut; it remains pinned. +Chat (`/chat`) and all existing Harness deep links retain their own route identity. +Quick Start selection never marks a Harness current until navigation enters it. +Below the utility list, Chat, +Quant, and Prediction each show up to four sessions from their current Workspace +(retaining an active older row), a new-session landing shortcut, and the shared +Workspace options menu. More conversations remain available in the browser +dialog. These are feature rows with their own icons, not collapsible folders or +a labeled Harness tree. Recent sessions stay visible with a shallow indent. +Trailing actions place options first and new-session last. The header owns the +single new-session action; empty lists do not repeat a New chat/research row. +These actions appear on header hover, keyboard focus, +or while the menu is open; touch devices keep them visible. Clicking a primary +navigation Session row enters its working surface: running Sessions open directly, +paused resumable Sessions restore through the existing runtime action. A pending +restore shows a spinner and rejects repeated clicks; failures stay on the row +and allow retry. Headless occupancy still opens the single-writer explanation. +The primary row has no separate play/stop target; settings, stop and archive live +in its options menu. Direct links and history browsers retain view-only opening. +Quant/Prediction retain their explicit default +Workspace readiness gates before exposing sessions and Studio. The navigation +distinguishes setup, existing-Workspace selection, loading, and +retryable errors. Without a Workspace, only the Harness header remains: clicking +it opens the existing setup landing flow, without creating or selecting a +Workspace. Do not repeat setup copy or a second setup button below it. Before +readiness, the new-research shortcut is hidden. +Studio is a compact outlined tool button with route-owned selection, separate +from conversation rows; Quant and Prediction share its presentation. +Harness working views use one content top bar, not a second conversation sidebar. +TerminalView has no card/canvas mode: its header always uses PageTopBar and its +single grid row fills the remaining height. Do not reserve a local header row +for portaled content; xterm's FitAddon measures the padding-free terminal host. +A compact rail keeps distinct Harness icons; mobile uses the same groups inside the global +drawer. Quick Start hands new Sessions to their existing Harness-owned routes, +not a second conversation hierarchy. The Settings editor reorders live: the list opens a gap under the pointer while the lifted row follows it. Sibling rows FLIP-animate into that slot. `prefers-reduced-motion: reduce` skips the sibling motion; the overlay still @@ -60,6 +104,18 @@ needed for the real task. Prefer a small rotating set over a wall of commands. ### Background execution surfaces +The bottom Your Alice application menu uses the static Alice portrait and a +text label when expanded, or only the portrait when compact. The brand header +keeps the OpenAlice wordmark without a second portrait. Its trailing ellipsis +appears on hover, keyboard focus, or while open; touch keeps it visible. The +trigger highlights for interaction, not because a Settings or Connectors page +is active. Settings remains an item inside this application menu. + +Connectors is accessed from the bottom Your Alice menu, alongside Settings and +above Appearance, not from the primary activity list or its layout editor. +The existing Connectors route and setup flows remain unchanged. Connector +health warnings appear on the Your Alice trigger and the Connectors menu item. + The Automation activity entry and its dedicated navigator are retired. Runs and API remain unchanged under Settings → Developer, at `/settings/developer/runs` and `/settings/developer/api`. Old Automation links @@ -77,7 +133,7 @@ actual Harness and preserve their target identity without mounting a global Workspace interface. Missing or unsupported membership is an explicit recovery state, not permission to guess a Harness from a tag or show the old manager. -The Harness footer identity opens the current Workspace's details in the same +The Harness options menu identity opens the current Workspace's details in the same Harness shell (`//workspaces/:wsId/details`); the adjacent chevron is an independent Workspace switcher. Keep configuration and conversation browsing as separate actions below it. Do not make the identity click switch Workspaces, @@ -210,10 +266,9 @@ keyboard navigation, outside dismissal, scroll locking, and focus return. control sits to the right of the OpenAlice brand. When compact, the expand control moves to the leading edge of the right-hand area's top bar. Only one copy is mounted; activation transfers keyboard focus to the new location. - Responsive compact mode is a default, never a lock. Chat, Quant, and - Prediction default compact and share a temporary expansion override while - inside that workbench group. Leaving the group clears the override and - restores the saved global preference; workbench toggles never rewrite it. + Responsive compact mode is a default, never a lock. Entering Chat, Quant, or + Prediction no longer auto-collapses the rail: it owns their session lists. + Explicit expanded/collapsed preferences apply across all product areas. - `TopBar` owns compact header geometry (40px desktop, at least 48px on phone). `PageContentLayout` owns a fixed header slot; `PageTopBar` portals a page's title and actions into it without copying business state or callbacks. @@ -283,7 +338,8 @@ The application body establishes a 14px type size with 20px leading. Explicit display, heading, control, caption, and data roles build from that stable reading baseline. -The compact activity rail retains its static Alice mark. Its expansion action +The compact activity rail keeps its static Alice mark in the bottom application +menu. Its expansion action lives in the content-side top bar, not in a brand-hover affordance. Small desktop windows still permit explicit expansion. The shell owns effective rail state so its toggle and the rendered rail always agree. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ed38809fe..090941b81 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,7 +16,6 @@ import { import { UrlAdopter } from './tabs/UrlAdopter' import { useLocale } from './i18n/useLocale' import { useActivityRailState } from './hooks/useActivityRailState' -import { useWorkspace } from './tabs/store' import { PrimaryNavigationContext } from './contexts/PrimaryNavigationContext' import { PrimaryNavigationToggle, useNavigationToggleFocus } from './components/PrimaryNavigationToggle' @@ -90,9 +89,7 @@ function AppShellContent() { const hasRailText = useHasRailText() // ≥960 — text rail is allowed const hasFullRail = useHasFullRail() // ≥1280 — full rail width const railMode = !isDesktop ? 'full' : hasFullRail ? 'full' : hasRailText ? 'narrow' : 'compact' - const area = useWorkspace((state) => state.selectedSidebar) - const workbench = area === 'chat' || area === 'auto-quant' || area === 'prediction' - const { collapsed: railCollapsed, toggle: toggleRail } = useActivityRailState(workbench, railMode === 'compact') + const { collapsed: railCollapsed, toggle: toggleRail } = useActivityRailState(railMode === 'compact') const toggleFocus = useNavigationToggleFocus() const railToggle = isDesktop ? ( { diff --git a/ui/src/components/ActivityBar.current-state.spec.tsx b/ui/src/components/ActivityBar.current-state.spec.tsx index f7437fde3..52c0e2c23 100644 --- a/ui/src/components/ActivityBar.current-state.spec.tsx +++ b/ui/src/components/ActivityBar.current-state.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ActivityBar } from './ActivityBar' const mocks = vi.hoisted(() => ({ + focusedKind: 'issue', selectedSidebar: 'issue', setSidebar: vi.fn(), openOrFocus: vi.fn(), @@ -27,6 +28,11 @@ vi.mock('../live/inbox-read', () => ({ useUnreadInboxCount: () => 0, })) +vi.mock('../tabs/types', () => ({ getFocusedTab: () => ({ spec: { kind: mocks.focusedKind } }) })) +vi.mock('./workspace/ChatWorkspaceSection', () => ({ + ChatWorkspaceSection: ({ mode }: { mode: string }) =>
, +})) + vi.mock('../live/trading-push', () => ({ usePendingPushCount: () => 0, })) @@ -47,7 +53,7 @@ vi.mock('../live/activity-bar-collapse', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => ({ - 'nav.item.chat': 'Ask Alice', + 'nav.quickStart': 'Quick Start', 'nav.item.issue': 'Issues', 'nav.item.connectors': 'Connectors', 'nav.connectorNeedsAttention': '1 connector needs attention', @@ -60,13 +66,19 @@ vi.mock('react-i18next', () => ({ })) vi.mock('./ActivityBarUtilityMenu', () => ({ - ActivityBarUtilityMenu: ({ onOpenSettings }: { onOpenSettings: () => void }) => ( - + ActivityBarUtilityMenu: ({ onOpenSettings, onOpenConnectors, connectorWarnings, connectorsActive }: { + onOpenSettings: () => void; onOpenConnectors: () => void; connectorWarnings: number; connectorsActive: boolean + }) => ( +
+ + +
), })) beforeEach(() => { mocks.selectedSidebar = 'issue' + mocks.focusedKind = 'issue' mocks.connectorWarnings = 0 mocks.railCollapsed = false vi.stubGlobal('matchMedia', vi.fn(() => ({ @@ -94,27 +106,45 @@ describe('ActivityBar current destination', () => { const { rerender } = render() expect(screen.getByRole('button', { name: 'Issues' }).getAttribute('aria-current')).toBe('page') - expect(screen.getByRole('button', { name: 'Ask Alice' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByRole('button', { name: 'Quick Start' }).getAttribute('aria-current')).toBeNull() expect(document.querySelectorAll('nav [aria-current="page"]')).toHaveLength(1) expect(screen.getByTestId('activity-bar').getAttribute('data-rail-layout')).toBe('full') - mocks.selectedSidebar = 'chat' + mocks.selectedSidebar = 'quick-start' + mocks.focusedKind = 'quick-start' rerender() - expect(screen.getByRole('button', { name: 'Ask Alice' }).getAttribute('aria-current')).toBe('page') - expect(screen.getByRole('button', { name: 'Ask Alice' }).getAttribute('aria-label')).toBe('Ask Alice') + expect(screen.getByRole('button', { name: 'Quick Start' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('button', { name: 'Quick Start' }).getAttribute('aria-label')).toBe('Quick Start') expect(screen.getByRole('button', { name: 'Issues' }).getAttribute('aria-current')).toBeNull() expect(document.querySelectorAll('nav [aria-current="page"]')).toHaveLength(1) expect(screen.getByTestId('activity-bar').getAttribute('data-rail-layout')).toBe('full') expect(screen.queryByRole('button', { name: 'Collapse activity bar' })).toBeNull() - expect(screen.getByTestId('activity-bar').firstElementChild?.querySelector('img')).toBeTruthy() + expect(screen.getByTestId('activity-bar').firstElementChild?.querySelector('img')).toBeNull() }) - it('shows configured connector degradation on the Connector activity item', () => { + it('moves Connectors and its warning state into the utility menu', () => { mocks.connectorWarnings = 1 - render() + mocks.selectedSidebar = 'connectors' + const onClose = vi.fn() + render() + expect(screen.queryByRole('button', { name: 'Connectors' })).toBeNull() + expect(screen.getByTestId('activity-bar-utility-state').dataset.warnings).toBe('1') + expect(screen.getByTestId('activity-bar-utility-state').dataset.active).toBe('true') + screen.getByRole('button', { name: 'Open Connectors' }).click() + expect(mocks.setSidebar).toHaveBeenCalledWith('connectors') + expect(mocks.openOrFocus).toHaveBeenCalledWith({ kind: 'connectors', params: {} }) + expect(onClose).toHaveBeenCalledOnce() + }) - expect(screen.getByLabelText('1 connector needs attention').textContent).toBe('1') + it.each(['workspace', 'chat-landing'])('keeps Quick Start separate from the Chat Harness (%s)', focusedKind => { + mocks.selectedSidebar = 'chat' + mocks.focusedKind = focusedKind + render() + expect(screen.getByRole('button', { name: 'Quick Start' }).hasAttribute('aria-current')).toBe(false) + expect(screen.getAllByTestId(/^harness-/).map(node => node.dataset.testid)).toEqual(['harness-chat', 'harness-auto-quant', 'harness-prediction']) + const tools = document.getElementById('activity-section-primary')! + expect(tools.contains(screen.getByTestId('harness-auto-quant'))).toBe(false) }) it('opens Settings from the application utility menu', () => { @@ -128,12 +158,12 @@ describe('ActivityBar current destination', () => { }) }) - it('leaves global toggling to the top bar and keeps its brand when compact', () => { + it('leaves global toggling to the top bar without duplicating the Alice portrait when compact', () => { mocks.railCollapsed = true render() const activityBar = screen.getByTestId('activity-bar') expect(activityBar.getAttribute('data-rail-layout')).toBe('compact') - expect(activityBar.firstElementChild?.querySelector('img')).toBeTruthy() + expect(activityBar.firstElementChild?.querySelector('img')).toBeNull() expect(screen.queryByRole('button', { name: /activity bar/ })).toBeNull() }) diff --git a/ui/src/components/ActivityBar.drawer-state.spec.tsx b/ui/src/components/ActivityBar.drawer-state.spec.tsx index 362267599..ce485dcd9 100644 --- a/ui/src/components/ActivityBar.drawer-state.spec.tsx +++ b/ui/src/components/ActivityBar.drawer-state.spec.tsx @@ -25,6 +25,11 @@ vi.mock('../live/inbox-read', () => ({ useUnreadInboxCount: () => 0, })) +vi.mock('../tabs/types', () => ({ getFocusedTab: () => ({ spec: { kind: 'issue' } }) })) +vi.mock('./workspace/ChatWorkspaceSection', () => ({ + ChatWorkspaceSection: ({ mode }: { mode: string }) => , +})) + vi.mock('../live/trading-push', () => ({ usePendingPushCount: () => 0, })) @@ -35,7 +40,7 @@ vi.mock('../live/connector-health', () => ({ vi.mock('../live/activity-bar-collapse', () => ({ useActivityBarCollapse: (selector: (state: Record) => unknown) => selector({ - collapsedSections: {}, + collapsedSections: { beta: true }, setCollapsed: mocks.setCollapsed, railCollapsed: false, setRailCollapsed: mocks.setRailCollapsed, @@ -45,7 +50,7 @@ vi.mock('../live/activity-bar-collapse', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => ({ - 'nav.item.chat': 'Ask Alice', + 'nav.quickStart': 'Quick Start', 'nav.item.issue': 'Issues', 'nav.item.automation': 'Automation', 'nav.section.beta': 'Beta', @@ -104,14 +109,14 @@ describe('ActivityBar mobile drawer state', () => { it('keeps mobile drawer actions tappable without changing desktop density', () => { render() - const primaryAction = screen.getByRole('button', { name: 'Ask Alice' }) - const sectionToggle = screen.getByRole('button', { name: 'Beta' }) + const primaryAction = screen.getByRole('button', { name: 'Quick Start' }) + const predictionAction = screen.getByRole('button', { name: 'prediction Harness' }) expect(primaryAction.className).toContain('min-h-10') expect(primaryAction.className).toContain('md:min-h-8') - expect(sectionToggle.className).toContain('min-h-10') - expect(sectionToggle.className).toContain('md:min-h-6') - expect(sectionToggle.getAttribute('title')).toBeNull() + expect(predictionAction.className).toContain('min-h-10') + expect(predictionAction.className).toContain('md:min-h-8') + expect(screen.queryByRole('button', { name: 'Beta' })).toBeNull() expect(screen.queryByRole('button', { name: 'nav.about' })).toBeNull() }) @@ -154,7 +159,7 @@ describe('ActivityBar mobile drawer state', () => { expect(drawer.getAttribute('aria-modal')).toBe('true') expect(firstAction.getAttribute('aria-label')).toBe('common.closePanel') - expect(firstDestination.textContent).toContain('Ask Alice') + expect(firstDestination.textContent).toContain('Quick Start') expect(lastAction.textContent).toContain('Project menu') await waitFor(() => expect(document.activeElement).toBe(currentDestination)) expect(drawer.className).toContain('motion-reduce:transition-none') diff --git a/ui/src/components/ActivityBar.spec.ts b/ui/src/components/ActivityBar.spec.ts index d56b80935..2d7ce9db3 100644 --- a/ui/src/components/ActivityBar.spec.ts +++ b/ui/src/components/ActivityBar.spec.ts @@ -6,7 +6,7 @@ import { editorGroupsFromLayout, filterNavSections, joinNavLayout, NAV_SECTIONS, describe('ActivityBar navigation hierarchy', () => { it('keeps the primary workflow ordered with Quant below Issues', () => { const primary = NAV_SECTIONS.find((section) => section.sectionLabel === '') - const beta = NAV_SECTIONS.find((section) => section.sectionLabel === 'Beta') + expect(NAV_SECTIONS.some((section) => section.id === 'beta')).toBe(false) const system = NAV_SECTIONS.find((section) => section.sectionLabel === 'System') expect(primary?.items.map((item) => item.page)).toEqual([ @@ -16,14 +16,11 @@ describe('ActivityBar navigation hierarchy', () => { 'auto-quant', 'tracked', 'market', - ]) - expect(beta?.items.map((item) => item.page)).toEqual([ 'prediction', 'office', 'portfolio', - 'connectors', ]) - expect(beta?.items.find((item) => item.page === 'portfolio')?.labelKey).toBe('nav.item.trading') + expect(primary?.items.find((item) => item.page === 'portfolio')?.labelKey).toBe('nav.item.trading') expect(system?.items).toEqual([]) }) @@ -37,13 +34,12 @@ describe('ActivityBar navigation hierarchy', () => { 'tracked', 'prediction', 'office', - 'connectors', ]) expect(pages).not.toContain('market') expect(pages).not.toContain('portfolio') }) - it('does not resurrect Workspaces or Automation from saved layouts or the editor', () => { + it('does not resurrect retired entries or Connectors from saved layouts or the editor', () => { const layout = defaultUiLayout() const pages = joinNavLayout(NAV_SECTIONS, layout, { office: true }).flatMap(s => s.items.map(i => i.page)) const editable = editorGroupsFromLayout(NAV_SECTIONS, layout).flatMap(g => g.items.map(i => i.page)) @@ -51,6 +47,8 @@ describe('ActivityBar navigation hierarchy', () => { expect(editable).not.toContain('workspaces') expect(pages).not.toContain('automation') expect(editable).not.toContain('automation') + expect(pages).not.toContain('connectors') + expect(editable).not.toContain('connectors') }) it('keeps Settings and Dev out of the default joined rail', () => { @@ -81,12 +79,29 @@ describe('ActivityBar navigation hierarchy', () => { it('hides Office unless the beta flag is on', () => { const hidden = filterNavSections(NAV_SECTIONS, { office: false }) const shown = filterNavSections(NAV_SECTIONS, { office: true }) - const hiddenBeta = hidden.find((section) => section.sectionLabel === 'Beta') - const shownBeta = shown.find((section) => section.sectionLabel === 'Beta') + const hiddenPrimary = hidden.find((section) => section.id === 'primary') + const shownPrimary = shown.find((section) => section.id === 'primary') + + expect(hiddenPrimary?.items.map((item) => item.page)).not.toContain('office') + expect(hiddenPrimary?.items.length).toBeGreaterThan(0) + expect(shownPrimary?.items.map((item) => item.page)).toContain('office') + expect(shownPrimary?.items[0]?.page).toBe('chat') + }) - expect(hiddenBeta?.items.map((item) => item.page)).not.toContain('office') - expect(hiddenBeta?.items.length).toBeGreaterThan(0) - expect(shownBeta?.items.map((item) => item.page)).toContain('office') - expect(shownBeta?.items[0]?.page).toBe('prediction') + it('flattens saved Beta entries consistently in the rail and editor', () => { + const layout: UiLayout = { + version: 1, + groups: [ + { id: 'primary', items: ['chat', 'inbox', 'issue', 'auto-quant', 'tracked', 'market'] }, + { id: 'beta', items: ['connectors', 'portfolio', 'prediction', 'office'] }, + ], + hidden: ['portfolio'], + } + const rail = joinNavLayout(NAV_SECTIONS, layout, { office: false }) + expect(rail.map(group => group.id)).toEqual(['primary']) + expect(rail[0].items.map(item => item.page).slice(-2)).toEqual(['market', 'prediction']) + const editor = editorGroupsFromLayout(NAV_SECTIONS, layout) + expect(editor.some(group => group.id === 'beta')).toBe(false) + expect(editor[0].items.find(item => item.page === 'portfolio')?.hidden).toBe(true) }) }) diff --git a/ui/src/components/ActivityBar.tsx b/ui/src/components/ActivityBar.tsx index c8c0ca6fd..e6208f2b7 100644 --- a/ui/src/components/ActivityBar.tsx +++ b/ui/src/components/ActivityBar.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } import { type Page } from '../App' import { useWorkspace } from '../tabs/store' import type { ActivitySection } from '../tabs/types' +import { getFocusedTab } from '../tabs/types' import { useUnreadInboxCount } from '../live/inbox-read' import { usePendingPushCount } from '../live/trading-push' import { useConnectorWarningCount } from '../live/connector-health' @@ -21,6 +22,7 @@ import { } from '@/components/ui/tooltip' import { SelectionIndicator } from './SelectionIndicator' import { Button } from '@/components/ui/button' +import { ChatWorkspaceSection } from './workspace/ChatWorkspaceSection' /** * Map ActivityBar page enum (visual layout grouping) to the ActivitySection @@ -81,7 +83,7 @@ function useMediaQuery(query: string): boolean { * (one elevation step up from the secondary Sidebar and the base main * pane) — rail → sidebar → main read as three distinct tiers. Top * section (no header) is the pinned product-navigation block — Chat, Inbox, - * Issues, etc. — always visible. Labeled sections (Beta, System) + * Issues, etc. — always visible. User-arranged labeled sections * get collapsible chevron headers; collapse state persists to * localStorage. * @@ -103,10 +105,13 @@ export function ActivityBar({ const officeNav = useBetaFeatures((s) => s.office) const { layout } = useUiLayout() const navSections = useMemo( - () => joinNavLayout(NAV_SECTIONS, layout, { product: project?.product, office: officeNav }), + () => joinNavLayout(NAV_SECTIONS, layout, { product: project?.product, office: officeNav }) + .map(section => ({ ...section, items: section.items.filter(item => item.page !== 'auto-quant' && item.page !== 'prediction') })) + .filter(section => section.items.length > 0), [layout, officeNav, project?.product], ) const selectedSidebar = useWorkspace((state) => state.selectedSidebar) + const focusedKind = useWorkspace((state) => getFocusedTab(state)?.spec.kind) const setSidebar = useWorkspace((state) => state.setSidebar) const openOrFocus = useWorkspace((state) => state.openOrFocus) const unreadInbox = useUnreadInboxCount() @@ -120,15 +125,11 @@ export function ActivityBar({ const narrowRail = desktopStatic && railMode !== 'full' && !compactRail const denseRail = desktopStatic && shortRailHeight const mobileDrawerRef = useRef(null) + const harnesses = (['chat', 'auto-quant', 'prediction'] as const) + .filter(mode => mode === 'chat' || !layout.hidden.includes(mode)) const railContent = ( <>
- Alice

OpenAlice

{!desktopStatic ? (
) })} +
+ {harnesses.map(mode => ( + + ))} +
{/* Application controls pinned to the bottom of the rail. */} @@ -275,7 +275,13 @@ export function ActivityBar({ { + setSidebar('connectors') + openOrFocus({ kind: 'connectors', params: {} }) + onClose() + }} onOpenSettings={() => { setSidebar('settings') openOrFocus({ kind: 'settings', params: { category: 'general' } }) @@ -287,7 +293,7 @@ export function ActivityBar({ ) const railClassName = ` - w-[280px] ${compactRail ? 'md:w-[50px]' : narrowRail ? 'md:w-[152px]' : 'md:w-[188px]'} h-full flex flex-col shrink-0 + w-[280px] ${compactRail ? 'md:w-[50px]' : narrowRail ? 'md:w-[232px]' : 'md:w-[260px]'} h-full flex flex-col shrink-0 bg-sidebar border-r border-sidebar-border/70 ` diff --git a/ui/src/components/ActivityBarUtilityMenu.spec.tsx b/ui/src/components/ActivityBarUtilityMenu.spec.tsx index 8aa289eba..9220e4318 100644 --- a/ui/src/components/ActivityBarUtilityMenu.spec.tsx +++ b/ui/src/components/ActivityBarUtilityMenu.spec.tsx @@ -21,9 +21,12 @@ vi.mock('../theme/store', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: { mode?: string }) => ({ - 'nav.applicationMenu': 'Open settings menu', + 'nav.applicationMenu': 'Your Alice: Open application menu', + 'nav.yourAlice': 'Your Alice', 'nav.appearanceMenu': `Appearance: ${params?.mode}`, 'nav.item.settings': 'Settings', + 'nav.item.connectors': 'Connectors', + 'nav.connectorNeedsAttention': '1 connector needs attention', 'settings.category.appearance': 'Appearance', 'theme.mode.auto': 'Auto', 'theme.mode.day': 'Day', @@ -46,19 +49,20 @@ describe('ActivityBarUtilityMenu', () => { , ) - await user.click(screen.getByRole('button', { name: 'Open settings menu' })) + await user.click(screen.getByRole('button', { name: 'Your Alice: Open application menu' })) expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeTruthy() + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Settings', 'Connectors', 'AppearanceAuto']) expect(screen.getByRole('menuitem', { name: 'Appearance: Auto' })).toBeTruthy() expect(screen.queryByRole('menuitemradio', { name: 'Auto' })).toBeNull() await user.click(screen.getByRole('menuitem', { name: 'Settings' })) expect(onOpenSettings).toHaveBeenCalledOnce() - await user.click(screen.getByRole('button', { name: 'Open settings menu' })) + await user.click(screen.getByRole('button', { name: 'Your Alice: Open application menu' })) screen.getByRole('menuitem', { name: 'Appearance: Auto' }).focus() await user.keyboard('{ArrowRight}') expect(screen.getByRole('menuitemradio', { name: 'Auto' }).getAttribute('aria-checked')).toBe('true') @@ -67,4 +71,26 @@ describe('ActivityBarUtilityMenu', () => { expect(mocks.setTheme).toHaveBeenCalledWith('night') }) + + it.each([false, true])('opens Connectors with keyboard and keeps warnings discoverable (compact=%s)', async (compactRail) => { + const user = userEvent.setup() + const onOpenConnectors = vi.fn() + render() + expect(screen.getByRole('status', { name: '1 connector needs attention' })).toBeTruthy() + const trigger = screen.getByRole('button', { name: 'Your Alice: Open application menu' }) + expect(trigger.querySelector('img')?.getAttribute('src')).toBe('/alice.ico') + expect(trigger.querySelector('img')?.parentElement?.classList.contains('rounded-full')).toBe(true) + expect(trigger.textContent).toBe(compactRail ? '' : 'Your Alice') + expect(trigger.className).not.toContain('bg-sidebar-accent text-sidebar-accent-foreground') + trigger.focus() + await user.keyboard('{ArrowDown}') + expect(trigger.className).toContain('bg-sidebar-accent text-sidebar-accent-foreground') + const connectors = screen.getByRole('menuitem', { name: /Connectors/ }) + expect(connectors.getAttribute('aria-current')).toBe('page') + connectors.focus() + await user.keyboard('{Enter}') + expect(onOpenConnectors).toHaveBeenCalledOnce() + expect(screen.queryByRole('menuitem', { name: /Connectors/ })).toBeNull() + }) }) diff --git a/ui/src/components/ActivityBarUtilityMenu.tsx b/ui/src/components/ActivityBarUtilityMenu.tsx index 88ee3a435..05c31aeaf 100644 --- a/ui/src/components/ActivityBarUtilityMenu.tsx +++ b/ui/src/components/ActivityBarUtilityMenu.tsx @@ -1,4 +1,4 @@ -import { Laptop, Moon, Settings, Sun } from 'lucide-react' +import { Ellipsis, Laptop, Moon, Plug, Settings, Sun } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -25,15 +25,19 @@ const THEME_MODES = [ interface ActivityBarUtilityMenuProps { compactRail: boolean denseRail: boolean - active: boolean onOpenSettings: () => void + onOpenConnectors: () => void + connectorsActive?: boolean + connectorWarnings?: number } export function ActivityBarUtilityMenu({ compactRail, denseRail, - active, onOpenSettings, + onOpenConnectors, + connectorsActive = false, + connectorWarnings = 0, }: ActivityBarUtilityMenuProps) { const { t } = useTranslation() const theme = useThemeStore((state) => state.theme) @@ -48,21 +52,33 @@ export function ActivityBarUtilityMenu({ + )} + + ) : <>
+
+ {menu} + {showNewAction && } +
+
+
{children}
+ + ) +} diff --git a/ui/src/components/workspace/HarnessWorkspaceEntry.tsx b/ui/src/components/workspace/HarnessWorkspaceEntry.tsx new file mode 100644 index 000000000..fbd5fcfee --- /dev/null +++ b/ui/src/components/workspace/HarnessWorkspaceEntry.tsx @@ -0,0 +1,38 @@ +import { AppWindow, ArrowRight } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '../ui/button' + +/** Workspace tools, visually distinct from the recent conversation rows. */ +export function HarnessWorkspaceEntry({ state, active, onOpen }: { + state: 'ready' | 'select' + active: boolean + onOpen: () => void +}) { + const { t } = useTranslation() + if (state !== 'ready') { + return ( +
+

+ {t('harnessNavigation.selectHint')} +

+ +
+ ) + } + return ( +
+ +
+ ) +} diff --git a/ui/src/components/workspace/ResumeCta.spec.tsx b/ui/src/components/workspace/ResumeCta.spec.tsx index fb9f3b1a1..904d4b33e 100644 --- a/ui/src/components/workspace/ResumeCta.spec.tsx +++ b/ui/src/components/workspace/ResumeCta.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionRecord } from './api' @@ -27,6 +27,29 @@ function record(runtime?: SessionRecord['runtime']): SessionRecord { afterEach(cleanup) describe('ResumeCta runtime facts', () => { + it('preserves a long title and all Pi actions without starting the session on mount', async () => { + const title = 'Research the complete cross-market impact of a changing policy regime across multiple portfolios' + const onResume = vi.fn(async () => {}) + const onOpenWebPi = vi.fn(async () => { throw new Error('Surface unavailable') }) + render( {})} + onResume={onResume} + onOpenWebPi={onOpenWebPi} + />) + + expect(screen.getByRole('heading', { name: title }).textContent).toBe(title) + expect(screen.getByRole('button', { name: 'Resume in TUI' })).toBeTruthy() + expect(document.querySelector('.resume-cta-actions')?.querySelectorAll('button')).toHaveLength(3) + expect(onResume).not.toHaveBeenCalled() + expect(onOpenWebPi).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Open in WebPi' })) + expect((await screen.findByRole('alert')).textContent).toBe('Surface unavailable') + expect(onResume).not.toHaveBeenCalled() + expect((screen.getByRole('button', { name: 'Resume in TUI' }) as HTMLButtonElement).disabled).toBe(false) + }) + it('shows the persisted Vault binding', () => { render(Session paused
- +

{sessionTitle}

@@ -85,15 +85,6 @@ export function ResumeCta(props: ResumeCtaProps): ReactElement {

-
- {runtimeFacts.map((fact) => ( -
-
{fact.label}
-
{fact.value}
-
- ))} -
-
{canOpenSettings && (
+ +
+ {runtimeFacts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
{error && ( diff --git a/ui/src/components/workspace/Sidebar.spec.tsx b/ui/src/components/workspace/Sidebar.spec.tsx index 0efdc83d1..10effcdf2 100644 --- a/ui/src/components/workspace/Sidebar.spec.tsx +++ b/ui/src/components/workspace/Sidebar.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,6 +9,56 @@ import { i18n } from '../../i18n' import type { AgentInfo, SessionRecord, Workspace } from './api' import { SessionRow, WorkspaceRow } from './Sidebar' +describe('navigation entry', () => { +const session: SessionRecord = { id: 's', resumeId: 'r', wsId: 'w', agent: 'pi', name: 'p', + createdAt: '2026-07-15T00:00:00.000Z', lastActiveAt: '2026-07-15T00:00:00.000Z', + state: 'paused', pid: null, startedAt: null, title: 'Review AAPL earnings' } +afterEach(cleanup) +it('enters paused navigation sessions once, reports failure, and permits retry', async () => { + let reject!: (reason: Error) => void + const onResume = vi.fn(() => new Promise((_, fail) => { reject = fail })) + render() + const row = screen.getByRole('button', { name: 'Review AAPL earnings' }) + fireEvent.click(row) + fireEvent.click(row) + expect(onResume).toHaveBeenCalledOnce() + expect(row.hasAttribute('disabled')).toBe(true) + expect(screen.queryByRole('button', { name: /^Resume / })).toBeNull() + reject(new Error('Runtime unavailable')) + expect((await screen.findByRole('alert')).textContent).toBe('Runtime unavailable') + await waitFor(() => expect(row.hasAttribute('disabled')).toBe(false)) + onResume.mockResolvedValueOnce(undefined) + fireEvent.click(row) + await waitFor(() => expect(row.hasAttribute('disabled')).toBe(false)) + expect(onResume).toHaveBeenCalledTimes(2) +}) + +it.each(['running', 'paused'] as const)('does not resume a non-resumable navigation session (%s)', state => { + const onSelect = vi.fn() + const onResume = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Review AAPL earnings' })) + expect(onSelect).toHaveBeenCalledOnce() + expect(onResume).not.toHaveBeenCalled() +}) +it('opens a running session directly and explains headless occupancy without spawning', () => { + const onSelect = vi.fn() + const onResume = vi.fn() + const onHeadlessBusy = vi.fn() + const props = { enterOnSelect: true, session: { ...session, state: 'running' as const }, + isActive: false, onSelect, onResume, onHeadlessBusy, onPause: vi.fn(), onDelete: vi.fn() } + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Review AAPL earnings' })) + expect(onSelect).toHaveBeenCalledOnce() + view.rerender() + fireEvent.click(screen.getByRole('button', { name: 'Running · Review AAPL earnings' })) + expect(onHeadlessBusy).toHaveBeenCalledOnce() + expect(onResume).not.toHaveBeenCalled() +}) +}) + const capabilities = { parallelPerCwd: true, resumeLast: true, diff --git a/ui/src/components/workspace/Sidebar.tsx b/ui/src/components/workspace/Sidebar.tsx index f45caf544..730965ac6 100644 --- a/ui/src/components/workspace/Sidebar.tsx +++ b/ui/src/components/workspace/Sidebar.tsx @@ -628,6 +628,7 @@ function HeadlessTaskRow(props: { } export interface SessionRowProps { + enterOnSelect?: boolean; reorderId?: string; session: SessionRecord; subtitle?: string; @@ -642,7 +643,7 @@ export interface SessionRowProps { /** Explain why an occupied headless Session cannot be opened yet. */ onHeadlessBusy?: () => void; onPause: () => void; - onResume: () => void; + onResume: () => void | Promise; onDelete: () => void; onArchive?: () => void; onRestore?: () => void; @@ -651,6 +652,9 @@ export interface SessionRowProps { export function SessionRow(props: SessionRowProps): ReactElement { const { t } = useTranslation(); + const pending = useRef(false); + const [opening, setOpening] = useState(false); + const [openError, setOpenError] = useState(null); const s = props.session; const isPaused = s.state === 'paused'; const headlessOccupying = props.headlessOccupying === true; @@ -671,7 +675,7 @@ export function SessionRow(props: SessionRowProps): ReactElement { const restoreLabel = t('workspace.restoreSession', { title: display }); const settingsLabel = t('workspace.sessionSettings.openFor', { title: display }); const menuItems = [ - ...(!headlessOccupying ? [isPaused ? { + ...(!headlessOccupying && !(props.enterOnSelect && isPaused) ? [isPaused ? { label: resumeLabel, ariaLabel: resumeLabel, icon: , @@ -711,6 +715,17 @@ export function SessionRow(props: SessionRowProps): ReactElement { }] : []), ]; const selectLabel = headlessOccupying ? t('workspace.sessionRunning', { title: display }) : display; + const enter = async () => { + if (pending.current) return; + if (headlessOccupying) { (props.onHeadlessBusy ?? props.onSelect)(); return; } + if (!props.enterOnSelect || !isPaused || !resumable) { props.onSelect(); return; } + pending.current = true; + setOpening(true); + setOpenError(null); + try { await props.onResume(); } + catch (error) { setOpenError(error instanceof Error ? error.message : String(error)); } + finally { pending.current = false; setOpening(false); } + }; let labelTone = 'text-foreground'; if (props.failed) labelTone = 'text-muted-foreground/70'; else if (isPaused && !headlessOccupying) labelTone = 'text-muted-foreground'; @@ -718,7 +733,7 @@ export function SessionRow(props: SessionRowProps): ReactElement {
void enter()} + disabled={opening} aria-label={selectLabel} aria-current={props.isActive ? 'page' : undefined} > {/* Runtime identity stays stable across Session state. The action at the right and the row treatment carry paused/running/selected state. */} - + {opening ? : } )} + {openError && {openError}} {/* Right-aligned, always-visible state-as-action: an interactive running Session shows STOP, a paused one shows PLAY, and headless occupancy shows live activity that opens the single-writer explanation. */} - {headlessOccupying ? ( + {!props.enterOnSelect && (headlessOccupying ? ( - )} + ))} {menuItems.length > 0 && ( - + -
+ } actions={<> {status === 'locked' && (
+ {/* FitAddon reads the computed size of xterm's direct parent. Keep that parent padding-free: putting the visual inset on `.terminal-host` makes FitAddon count the padding as usable columns, so the xterm diff --git a/ui/src/components/workspace/WorkspaceView.spec.tsx b/ui/src/components/workspace/WorkspaceView.spec.tsx index 5ca7d6951..c6ae6e5e0 100644 --- a/ui/src/components/workspace/WorkspaceView.spec.tsx +++ b/ui/src/components/workspace/WorkspaceView.spec.tsx @@ -239,7 +239,6 @@ describe('WorkspaceView terminal canvas', () => { label: 'AutoQuant', sessionLabel: 'Conversation 2', headerActions, - chrome: 'canvas', })) }) }) diff --git a/ui/src/components/workspace/WorkspaceView.tsx b/ui/src/components/workspace/WorkspaceView.tsx index 830f24e5b..a323e4f30 100644 --- a/ui/src/components/workspace/WorkspaceView.tsx +++ b/ui/src/components/workspace/WorkspaceView.tsx @@ -148,7 +148,6 @@ export function WorkspaceView(props: WorkspaceViewProps): ReactElement { {...(props.label !== undefined ? { label: props.label } : {})} sessionLabel={sessionCoworkerLabel(s)} headerActions={props.terminalHeaderActions} - chrome="canvas" onSessionLost={props.onSessionLost} /> )} diff --git a/ui/src/components/workspace/__tests__/terminalLayout.spec.ts b/ui/src/components/workspace/__tests__/terminalLayout.spec.ts index ecad5c227..a989272d3 100644 --- a/ui/src/components/workspace/__tests__/terminalLayout.spec.ts +++ b/ui/src/components/workspace/__tests__/terminalLayout.spec.ts @@ -39,7 +39,10 @@ describe('terminal responsive layout contract', () => { it('allows the terminal shell to shrink while the shared toolbar owns header geometry', () => { expect(declarationsFor('.terminal-shell')).toContain('min-width: 0') const source = readFileSync(resolve(uiRoot, 'src/components/workspace/Terminal.tsx'), 'utf8') - expect(source).toContain("props.chrome === 'canvas' ? PageTopBar : TopBar") + expect(source).toContain(' { diff --git a/ui/src/components/workspace/workspaces.css b/ui/src/components/workspace/workspaces.css index 483acbca3..969a02f6f 100644 --- a/ui/src/components/workspace/workspaces.css +++ b/ui/src/components/workspace/workspaces.css @@ -23,6 +23,8 @@ .resume-cta-frame { flex: 1 1 auto; + min-width: 0; + min-height: 0; position: relative; overflow: hidden; background: var(--secondary); @@ -167,9 +169,11 @@ position: absolute; inset: 0; display: flex; - align-items: flex-end; - justify-content: center; - padding: clamp(16px, 3vw, 32px); + flex-direction: column; + align-items: center; + justify-content: flex-start; + overflow-y: auto; + padding: clamp(12px, 3cqi, 28px); background: linear-gradient( to bottom, transparent 36%, @@ -183,12 +187,15 @@ display: flex; flex-direction: column; align-items: stretch; - gap: 10px; - padding: 14px 16px 9px; - width: min(960px, 100%); + flex: 0 0 auto; + gap: 14px; + margin-top: auto; + padding: 20px 20px 10px; + width: min(800px, 100%); min-width: 0; background: color-mix(in srgb, var(--background) 80%, var(--secondary)); - border-block: 1px solid var(--border); + border: 1px solid var(--border); + border-radius: 10px; box-shadow: 0 -18px 44px -38px color-mix(in srgb, var(--shadow-color) 58%, transparent); transition: opacity var(--motion-standard) ease, @@ -196,18 +203,21 @@ } .resume-cta-card-main { - display: grid; - grid-template-columns: minmax(170px, 1.15fr) minmax(340px, 2fr) auto; + /* Identity and actions share a row only when the actual pane has room. + Runtime facts own the next row, never a competing fixed-width column. */ + display: flex; + flex-wrap: wrap; align-items: center; - gap: 16px; + gap: 18px 24px; min-width: 0; } .resume-cta-card-header { + flex: 1 1 260px; display: flex; flex-direction: column; align-items: flex-start; - gap: 2px; + gap: 6px; min-width: 0; text-align: left; } @@ -218,27 +228,27 @@ gap: 7px; margin-bottom: 1px; color: var(--muted-foreground); - font-size: 10px; + font-size: 11px; font-weight: 600; } .resume-cta-name-row { display: flex; + width: 100%; min-width: 0; - align-items: center; + align-items: flex-start; gap: 8px; } .resume-cta-name { margin: 0; + min-width: 0; max-width: 100%; - overflow: hidden; color: var(--foreground); font-size: 16px; font-weight: 600; - line-height: 1.3; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.4; + overflow-wrap: anywhere; } .resume-cta-state { @@ -248,10 +258,13 @@ } .resume-cta-runtime { + flex: 1 0 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); min-width: 0; margin: 0; + padding-top: 14px; + border-top: 1px solid var(--border); } .resume-cta-runtime-fact { @@ -260,28 +273,30 @@ border-left: 1px solid var(--border); } +.resume-cta-runtime-fact:first-child { + border-left: 0; + padding-left: 0; +} + .resume-cta-runtime-fact dt { margin: 0 0 2px; color: var(--muted-foreground); - font-size: 9px; + font-size: 11px; font-weight: 600; } .resume-cta-runtime-fact dd { min-width: 0; margin: 0; - overflow: hidden; color: var(--foreground); - font-family: ui-monospace, "SF Mono", Menlo, monospace; - font-size: 11px; + font-size: 12px; line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .resume-cta-meta { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto minmax(0, 1fr); column-gap: 14px; row-gap: 4px; margin: 0; @@ -327,12 +342,18 @@ border-radius: 4px; font-size: 13px; font-weight: 600; - white-space: nowrap; + min-height: 36px; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; cursor: pointer; transition: filter var(--motion-fast) ease, opacity var(--motion-fast) ease; } +.resume-cta-btn > svg { + flex-shrink: 0; +} .resume-cta-beta { padding: 1px 4px; border: 1px solid currentColor; @@ -364,12 +385,12 @@ .resume-cta-details > summary { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 5px; min-height: 30px; margin-bottom: -5px; color: var(--muted-foreground); - font-size: 11px; + font-size: 12px; list-style: none; cursor: pointer; } @@ -417,44 +438,17 @@ } } -@container resume-cta (max-width: 760px) { - .resume-cta-card-main { - grid-template-columns: minmax(0, 1fr) auto; - } - - .resume-cta-runtime { - grid-column: 1 / -1; - grid-row: 2; - } - - .resume-cta-runtime-fact:first-child { - border-left: 0; - padding-left: 0; - } -} - @container resume-cta (max-width: 480px) { .resume-cta-overlay { padding: 12px; } - .resume-cta-card-main { - grid-template-columns: minmax(0, 1fr); - gap: 11px; - } - - .resume-cta-runtime, - .resume-cta-actions { - grid-column: 1; - } - - .resume-cta-runtime { - grid-row: auto; + .resume-cta-card { + padding: 16px 14px 10px; } .resume-cta-actions { width: 100%; - flex-direction: column; } } @@ -1221,17 +1215,18 @@ button.files-row:focus-visible { .terminal-shell { display: grid; - grid-template-rows: auto 1fr; + /* PageTopBar portals outside this grid; only the terminal body is a row. */ + grid-template-rows: minmax(0, 1fr); width: 100%; max-width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; - border: 1px solid var(--border); - border-radius: 8px; + border: 0; + border-radius: 0; overflow: hidden; background: var(--secondary); - box-shadow: 0 10px 30px color-mix(in srgb, var(--shadow-color) 35%, transparent); + box-shadow: none; } /* A pinned terminal is the Workspace page's primary canvas, not a card inside @@ -1246,11 +1241,6 @@ button.files-row:focus-visible { border-radius: 0; } -.terminal-shell.is-canvas { - border: 0; - border-radius: 0; - box-shadow: none; -} .status-dot { @@ -1293,6 +1283,9 @@ button.files-row:focus-visible { } .resume-cta-actions { display: flex; + flex-wrap: wrap; + max-width: 100%; + min-width: 0; gap: 8px; justify-content: flex-end; } @@ -1510,6 +1503,9 @@ button.files-row:focus-visible { .webpi-composer-hint { text-align: left; } } -@media (max-width: 720px) { - .resume-cta-actions .resume-cta-btn { flex: 1; } +@container resume-cta (max-width: 480px) { + .resume-cta-actions .resume-cta-btn { + flex: 1 1 140px; + min-height: 40px; + } } diff --git a/ui/src/hooks/useActivityRailState.spec.ts b/ui/src/hooks/useActivityRailState.spec.ts index 24802640a..901949448 100644 --- a/ui/src/hooks/useActivityRailState.spec.ts +++ b/ui/src/hooks/useActivityRailState.spec.ts @@ -7,35 +7,31 @@ import { useActivityRailState } from './useActivityRailState' beforeEach(() => useActivityBarCollapse.setState({ railCollapsed: false })) afterEach(cleanup) -describe('contextual activity rail', () => { - it.each([true, false])('restores saved preference %s after temporary workbench expansion', (saved) => { +describe('primary activity rail', () => { + it.each([true, false])('preserves saved preference %s across rerenders', (saved) => { useActivityBarCollapse.setState({ railCollapsed: saved }) - const view = renderHook(({ workbench }) => useActivityRailState(workbench, false), { initialProps: { workbench: false } }) + const view = renderHook(() => useActivityRailState(false)) expect(view.result.current.collapsed).toBe(saved) - view.rerender({ workbench: true }) - expect(view.result.current.collapsed).toBe(true) - act(() => view.result.current.toggle()) - expect(view.result.current.collapsed).toBe(false) - expect(useActivityBarCollapse.getState().railCollapsed).toBe(saved) - view.rerender({ workbench: false }) + view.rerender() expect(view.result.current.collapsed).toBe(saved) - view.rerender({ workbench: true }) - expect(view.result.current.collapsed).toBe(true) + act(() => view.result.current.toggle()) + expect(view.result.current.collapsed).toBe(!saved) + expect(useActivityBarCollapse.getState().railCollapsed).toBe(!saved) + view.rerender() + expect(view.result.current.collapsed).toBe(!saved) }) - it('allows manual expansion at compact widths, both inside and outside a workbench', () => { + it('allows manual expansion at compact widths and preserves it across breakpoints', () => { useActivityBarCollapse.setState({ railCollapsed: null }) - const view = renderHook(({ workbench }) => useActivityRailState(workbench, true), { initialProps: { workbench: false } }) + const view = renderHook(({ compact }) => useActivityRailState(compact), { initialProps: { compact: true } }) expect(view.result.current.collapsed).toBe(true) act(() => view.result.current.toggle()) expect(view.result.current.collapsed).toBe(false) expect(useActivityBarCollapse.getState().railCollapsed).toBe(false) - view.rerender({ workbench: true }) - expect(view.result.current.collapsed).toBe(true) - act(() => view.result.current.toggle()) + view.rerender({ compact: false }) expect(view.result.current.collapsed).toBe(false) act(() => view.result.current.toggle()) expect(view.result.current.collapsed).toBe(true) - expect(useActivityBarCollapse.getState().railCollapsed).toBe(false) + expect(useActivityBarCollapse.getState().railCollapsed).toBe(true) }) }) diff --git a/ui/src/hooks/useActivityRailState.ts b/ui/src/hooks/useActivityRailState.ts index eb9fe1981..52e3d1784 100644 --- a/ui/src/hooks/useActivityRailState.ts +++ b/ui/src/hooks/useActivityRailState.ts @@ -1,19 +1,10 @@ -import { useEffect, useState } from 'react' import { useActivityBarCollapse } from '../live/activity-bar-collapse' -/** Workbench expansion is temporary. Never overwrite the user's saved global - * preference just because a feature needs extra canvas space. */ -export function useActivityRailState(workbench: boolean, compactByDefault: boolean) { +/** The rail owns Harness sessions. Entering a Harness must not hide its list. */ +export function useActivityRailState(compactByDefault: boolean) { const preference = useActivityBarCollapse((state) => state.railCollapsed) const setPreference = useActivityBarCollapse((state) => state.setRailCollapsed) - const [workbenchExpanded, setWorkbenchExpanded] = useState(false) - useEffect(() => { - if (!workbench) setWorkbenchExpanded(false) - }, [workbench]) - const collapsed = workbench ? !workbenchExpanded : preference ?? compactByDefault - const toggle = () => { - if (workbench) setWorkbenchExpanded(!workbenchExpanded) - else setPreference(!collapsed) - } + const collapsed = preference ?? compactByDefault + const toggle = () => setPreference(!collapsed) return { collapsed, toggle } } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2ade6a9e5..ad22bf1ba 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -31,13 +31,17 @@ export const en = { noReadme: "There is no README.md in this Workspace yet. You can ask your agent to write an overview of its purpose and contents.", noGuide: "No Harness guide is available.", }, + quickStart: { chooseHarness: 'Choose Harness' }, nav: { + quickStart: 'Quick Start', + generalChat: 'Chat', + harnessLabel: '{{name}} Harness', item: { inbox: 'Inbox', tracked: 'Tracked', chat: 'Ask Alice', - autoQuant: 'Quant', - autoPrediction: 'Prediction', + autoQuant: 'Auto Quant', + autoPrediction: 'Auto Prediction', workspaces: 'Workspaces', market: 'Market', news: 'News', @@ -61,7 +65,8 @@ export const en = { connectorNeedsAttention: '{{count}} connector needs attention', about: 'About {{label}}', primaryNavigation: 'Primary navigation', - applicationMenu: 'Open settings menu', + yourAlice: 'Your Alice', + applicationMenu: 'Your Alice: Open application menu', appearanceMenu: 'Appearance: {{mode}}', collapseRail: 'Collapse activity bar', expandRail: 'Expand activity bar', @@ -1123,6 +1128,11 @@ export const en = { deleteSessionMessage: 'Delete "{{title}}"? This removes the conversation and its history. The workspace stays.', noChatWorkspacesYet: 'no chat workspaces yet', }, + harnessNavigation: { + openStudio: 'Open Studio', + selectHint: 'Choose a workspace to continue researching.', + selectAction: 'Choose workspace', + }, harnessSurface: { studio: 'Studio', refresh: 'Refresh', @@ -1159,8 +1169,8 @@ export const en = { workspacePickerDescription: 'Choose the durable research desk for your next assignment.', workspaceSessionCount: '{{count}} research sessions', workspaceSettings: 'AutoQuant workspace', - workspaceContextLabel: 'Quant workspace: {{name}}', - workspaceContextMenu: 'Quant Workspace options', + workspaceContextLabel: 'Auto Quant workspace: {{name}}', + workspaceContextMenu: 'Auto Quant Workspace options', configureWorkspace: 'Configure this workspace', useWorkspace: 'Use {{workspace}} as default', manageWorkspaces: 'Manage all workspaces', @@ -1191,8 +1201,8 @@ export const en = { recentResearch: 'Recent prediction research', viewAllResearch: 'View all {{count}} prediction sessions', browseResearch: 'Browse prediction research', - workspaceContextLabel: 'Prediction workspace context: {{name}}', - workspaceContextMenu: 'Prediction workspace context menu', + workspaceContextLabel: 'Auto Prediction workspace context: {{name}}', + workspaceContextMenu: 'Auto Prediction workspace context menu', workspacePickerDescription: 'Choose the Auto Prediction workspace that receives new research.', workspaceSessionCount_one: '{{count}} research session', workspaceSessionCount_other: '{{count}} research sessions', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 3a3c9a660..84549de64 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -20,13 +20,17 @@ export const ja: Resources = { noReadme: "このワークスペースにはまだ README.md がありません。目的や内容の概要をエージェントに作成してもらえます。", noGuide: "Harness ガイドはありません。", }, + quickStart: { chooseHarness: 'Harness を選択' }, nav: { + quickStart: 'Quick Start', + generalChat: 'Chat', + harnessLabel: '{{name}} Harness', item: { inbox: '受信トレイ', tracked: 'トラッキング', chat: 'Alice に質問', - autoQuant: 'Quant', - autoPrediction: 'Prediction', + autoQuant: 'Auto Quant', + autoPrediction: 'Auto Prediction', workspaces: 'ワークスペース', market: 'マーケット', news: 'ニュース', @@ -50,7 +54,8 @@ export const ja: Resources = { connectorNeedsAttention: '要確認のコネクター {{count}} 件', about: '{{label}}について', primaryNavigation: 'メインナビゲーション', - applicationMenu: '設定メニューを開く', + yourAlice: 'Your Alice', + applicationMenu: 'Your Alice:アプリメニューを開く', appearanceMenu: '外観:{{mode}}', collapseRail: 'アクティビティバーを折りたたむ', expandRail: 'アクティビティバーを展開', @@ -1112,6 +1117,11 @@ export const ja: Resources = { deleteSessionMessage: '「{{title}}」を削除しますか?会話とその履歴が削除されます。ワークスペースは残ります。', noChatWorkspacesYet: 'チャットワークスペースはまだありません', }, + harnessNavigation: { + openStudio: 'Studio を開く', + selectHint: 'ワークスペースを選んでリサーチを続けましょう。', + selectAction: 'ワークスペースを選択', + }, harnessSurface: { studio: 'Studio', refresh: '再読み込み', @@ -1148,8 +1158,8 @@ export const ja: Resources = { workspacePickerDescription: '次の依頼で使用する継続的なリサーチデスクを選択します。', workspaceSessionCount: '{{count}} 件のリサーチセッション', workspaceSettings: 'AutoQuant ワークスペース', - workspaceContextLabel: 'Quant ワークスペース:{{name}}', - workspaceContextMenu: 'Quant ワークスペースのオプション', + workspaceContextLabel: 'Auto Quant ワークスペース:{{name}}', + workspaceContextMenu: 'Auto Quant ワークスペースのオプション', configureWorkspace: '現在のワークスペースを設定', useWorkspace: '{{workspace}} をデフォルトにする', manageWorkspaces: 'すべてのワークスペースを管理', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 4e1df4cd3..5ac74806c 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -28,13 +28,17 @@ export const zhHant: Resources = { noReadme: "此工作區還沒有 README.md。你可以讓 Agent 撰寫一份說明,介紹它的用途和內容。", noGuide: "暫無 Harness 指南。", }, + quickStart: { chooseHarness: '選擇 Harness' }, nav: { + quickStart: 'Quick Start', + generalChat: 'Chat', + harnessLabel: '{{name}} Harness', item: { inbox: '收件匣', tracked: '追蹤', chat: '問 Alice', - autoQuant: 'AutoQuant', - autoPrediction: 'Prediction', + autoQuant: 'Auto Quant', + autoPrediction: 'Auto Prediction', workspaces: '工作區', market: '市場', news: '新聞', @@ -58,7 +62,8 @@ export const zhHant: Resources = { connectorNeedsAttention: '{{count}} 個連接器需要處理', about: '關於{{label}}', primaryNavigation: '主要導覽', - applicationMenu: '開啟設定選單', + yourAlice: 'Your Alice', + applicationMenu: 'Your Alice:開啟應用程式選單', appearanceMenu: '外觀:{{mode}}', collapseRail: '收合活動列', expandRail: '展開活動列', @@ -1119,6 +1124,11 @@ export const zhHant: Resources = { deleteSessionMessage: '刪除「{{title}}」?這會移除該對話及其歷史,工作區保留。', noChatWorkspacesYet: '尚無對話工作區', }, + harnessNavigation: { + openStudio: '開啟 Studio', + selectHint: '選擇一個工作區,繼續研究。', + selectAction: '選擇工作區', + }, harnessSurface: { studio: 'Studio', refresh: '重新整理', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index 4b7929e6e..c3ab0854e 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -20,13 +20,17 @@ export const zh: Resources = { noReadme: "此工作区还没有 README.md。你可以让 Agent 撰写一份说明,介绍它的用途和内容。", noGuide: "暂无 Harness 指南。", }, + quickStart: { chooseHarness: '选择 Harness' }, nav: { + quickStart: 'Quick Start', + generalChat: 'Chat', + harnessLabel: '{{name}} Harness', item: { inbox: '收件箱', tracked: '追踪', chat: '问 Alice', - autoQuant: 'Quant', - autoPrediction: 'Prediction', + autoQuant: 'Auto Quant', + autoPrediction: 'Auto Prediction', workspaces: '工作区', market: '市场', news: '新闻', @@ -50,7 +54,8 @@ export const zh: Resources = { connectorNeedsAttention: '{{count}} 个连接器需要处理', about: '关于{{label}}', primaryNavigation: '主导航', - applicationMenu: '打开设置菜单', + yourAlice: 'Your Alice', + applicationMenu: 'Your Alice:打开应用菜单', appearanceMenu: '外观:{{mode}}', collapseRail: '折叠活动栏', expandRail: '展开活动栏', @@ -1111,6 +1116,11 @@ export const zh: Resources = { deleteSessionMessage: '删除「{{title}}」?这会移除该对话及其历史,工作区保留。', noChatWorkspacesYet: '还没有对话工作区', }, + harnessNavigation: { + openStudio: '打开 Studio', + selectHint: '选择一个工作区,继续研究。', + selectAction: '选择工作区', + }, harnessSurface: { studio: 'Studio', refresh: '刷新', diff --git a/ui/src/index.css b/ui/src/index.css index bdc22ae86..160c74f17 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -376,6 +376,30 @@ body { } @media (hover: hover) and (pointer: fine) { + /* Keep actions keyboard-reachable; focus reveals them before activation. */ + .oa-application-menu-more { + opacity: 0; + transition: opacity var(--motion-fast); + } + + .oa-application-menu:hover .oa-application-menu-more, + .oa-application-menu:focus-visible .oa-application-menu-more, + .oa-application-menu[aria-expanded="true"] .oa-application-menu-more { + opacity: 1; + } + + .oa-harness-nav-actions { + opacity: 0; + pointer-events: none; + } + + .oa-harness-nav-header:hover .oa-harness-nav-actions, + .oa-harness-nav-header:focus-within .oa-harness-nav-actions, + .oa-harness-nav-actions:has([aria-expanded="true"]) { + opacity: 1; + pointer-events: auto; + } + .oa-session-overflow-action { position: absolute; inset-inline-end: 0.5rem; diff --git a/ui/src/live/ui-layout.spec.ts b/ui/src/live/ui-layout.spec.ts index 8cbe5a569..91d6b1f47 100644 --- a/ui/src/live/ui-layout.spec.ts +++ b/ui/src/live/ui-layout.spec.ts @@ -19,7 +19,7 @@ describe('ui-layout document', () => { const catalogPages = NAV_SECTIONS.flatMap((section) => section.items.map((item) => item.page)) // Persisted layout validation stays compatible; the navigation catalog // decides which accepted ids can actually appear or be configured. - expect(new Set(catalogPages)).toEqual(new Set(ACTIVITY_PAGE_IDS.filter(page => page !== 'workspaces' && page !== 'automation'))) + expect(new Set(catalogPages)).toEqual(new Set(ACTIVITY_PAGE_IDS.filter(page => page !== 'workspaces' && page !== 'automation' && page !== 'connectors'))) expect(defaultUiLayout().hidden).toEqual([]) expect(defaultUiLayout().hidden).not.toContain(PINNED_ACTIVITY_PAGE) }) @@ -69,13 +69,36 @@ describe('ui-layout document', () => { groups: [{ id: 'beta', items: ['office', 'trading-as-git', 'portfolio', 'connectors'] }], hidden: ['trading-as-git', 'dev'], }) - expect(layout.groups.find((group) => group.id === 'beta')?.items).toEqual([ + expect(layout.groups.some((group) => group.id === 'beta')).toBe(false) + expect(layout.groups.find((group) => group.id === 'primary')?.items.slice(0, 3)).toEqual([ 'office', 'portfolio', 'connectors', - 'prediction', ]) expect(layout.hidden).not.toContain('trading-as-git') expect(layout.hidden).toEqual([]) }) + + it('projects old Beta layouts without losing custom groups, ordering or visibility', () => { + const saved = { + version: 1, + groups: [ + { id: 'beta', items: ['connectors', 'prediction'] }, + { id: 'custom:desk', label: 'Desk', items: ['office', 'portfolio'] }, + { id: 'primary', items: ['market', 'chat', 'inbox', 'issue', 'auto-quant', 'tracked'] }, + ], + hidden: ['prediction'], + } + const original = structuredClone(saved) + const layout = normalizeUiLayout(saved) + expect(saved).toEqual(original) + expect(layout.groups.map(group => group.id)).toEqual(['custom:desk', 'primary', 'system']) + expect(layout.groups[0]).toEqual(saved.groups[1]) + expect(layout.groups[1].items).toEqual([...saved.groups[2].items, 'connectors', 'prediction']) + expect(layout.hidden).toEqual(['prediction']) + expect(normalizeUiLayout(layout)).toEqual(layout) + expect(normalizeUiLayout(defaultUiLayout())).toEqual(defaultUiLayout()) + const reordered = movePage(layout, 'prediction', 'primary', 1) + expect(reordered.groups[1].items.slice(0, 3)).toEqual(['market', 'prediction', 'chat']) + }) }) diff --git a/ui/src/live/ui-layout.ts b/ui/src/live/ui-layout.ts index 6b3b0bf08..051225c36 100644 --- a/ui/src/live/ui-layout.ts +++ b/ui/src/live/ui-layout.ts @@ -17,6 +17,7 @@ export const ACTIVITY_PAGE_IDS = [ export type ActivityPageId = (typeof ACTIVITY_PAGE_IDS)[number] +// Continue accepting the retired Beta id in saved version-1 layouts. export const BUILTIN_GROUP_IDS = ['primary', 'beta', 'system'] as const export type BuiltinGroupId = (typeof BUILTIN_GROUP_IDS)[number] @@ -43,8 +44,7 @@ export function defaultUiLayout(): UiLayout { return { version: 1, groups: [ - { id: 'primary', items: ['chat', 'inbox', 'issue', 'auto-quant', 'tracked', 'market'] }, - { id: 'beta', items: ['prediction', 'office', 'portfolio', 'connectors'] }, + { id: 'primary', items: ['chat', 'inbox', 'issue', 'auto-quant', 'tracked', 'market', 'prediction', 'office', 'portfolio', 'connectors'] }, { id: 'system', items: ['workspaces', 'automation'] }, ], hidden: [], @@ -109,7 +109,14 @@ export function normalizeUiLayout(input: unknown): UiLayout { if (!groups.some((group) => group.id === 'primary')) { groups.unshift({ id: 'primary', items: [] }) } - for (const id of ['beta', 'system'] as const) { + // Project the old Beta group into primary for both the rail and its editor. + // This does not rewrite stored state on read or move custom-group entries. + const betaIndex = groups.findIndex((group) => group.id === 'beta') + if (betaIndex >= 0) { + const [beta] = groups.splice(betaIndex, 1) + groups.find((group) => group.id === 'primary')!.items.push(...beta.items) + } + for (const id of ['system'] as const) { if (!groups.some((group) => group.id === id)) { groups.push({ id, items: [] }) } diff --git a/ui/src/pages/ActivityBarSettingsPage.spec.tsx b/ui/src/pages/ActivityBarSettingsPage.spec.tsx index 4010849b5..9b52b3d53 100644 --- a/ui/src/pages/ActivityBarSettingsPage.spec.tsx +++ b/ui/src/pages/ActivityBarSettingsPage.spec.tsx @@ -98,7 +98,7 @@ describe('ActivityBarSettingsPage', () => { } try { render() - fireEvent.pointerDown(screen.getByRole('button', { name: 'Reorder Ask Alice' }), { + fireEvent.pointerDown(screen.getByRole('button', { name: 'Reorder Quick Start' }), { pointerId: 1, button: 0, clientX: 20, diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx index 2f96ec7be..11b9e8634 100644 --- a/ui/src/pages/ChatLandingPage.tsx +++ b/ui/src/pages/ChatLandingPage.tsx @@ -87,6 +87,12 @@ export { resolveChatWorkspaceTarget } from '../lib/chat-workspace-target' */ type HarnessLandingMode = 'chat' | 'auto-quant' | 'prediction' +interface LandingPageProps { + spec: { params: { targetWsId?: string; initialPrompt?: string } } + showHeader?: boolean + onPromptChange?: (prompt: string) => void +} + const WORKFLOW_ICONS: Readonly> = { market: ChartNoAxesCombined, portfolio: BriefcaseBusiness, @@ -260,8 +266,9 @@ function HarnessWorkspacePicker({ function HarnessLandingPage({ spec, mode, -}: { - spec: { params: { targetWsId?: string; initialPrompt?: string } } + showHeader = true, + onPromptChange, +}: LandingPageProps & { mode: HarnessLandingMode }) { const { t } = useTranslation() @@ -320,7 +327,11 @@ function HarnessLandingPage({ // loop, so it can't be seeded with a first message). const cliAgents = agents.filter((a) => a.kind !== 'utility') - const [value, setValue] = useState(spec.params.initialPrompt ?? '') + const [value, setDraftValue] = useState(spec.params.initialPrompt ?? '') + const setValue = (next: string) => { + setDraftValue(next) + onPromptChange?.(next) + } const [launching, setLaunching] = useState(false) const [error, setError] = useState(null) const [examplePage, setExamplePage] = useState(0) @@ -448,7 +459,7 @@ function HarnessLandingPage({ data-testid="harness-landing-root" className="@container/harness flex h-full min-h-0 w-full flex-col overflow-hidden bg-background" > - + {showHeader && }
workspace.template === 'chat') if (!hasChatWorkspace) return - return + return } -export function AutoQuantLandingPage({ spec }: { spec: { params: { targetWsId?: string; initialPrompt?: string } } }) { +export function AutoQuantLandingPage({ spec, ...presentation }: LandingPageProps) { const ctx = useWorkspaces() const workspace = ctx.workspaces.find((candidate) => candidate.id === ctx.autoQuantDefaultWorkspaceId && candidate.template === 'auto-quant-v2') if (!workspace) return - return + return } -export function AutoPredictionLandingPage({ spec }: { spec: { params: { targetWsId?: string; initialPrompt?: string } } }) { +export function AutoPredictionLandingPage({ spec, ...presentation }: LandingPageProps) { const ctx = useWorkspaces() const workspace = ctx.workspaces.find((candidate) => candidate.id === ctx.autoPredictionDefaultWorkspaceId && candidate.template === 'auto-prediction') if (!workspace) return - return + return } diff --git a/ui/src/pages/ChatPageShell.spec.tsx b/ui/src/pages/ChatPageShell.spec.tsx index 864d66bb9..813a70f1e 100644 --- a/ui/src/pages/ChatPageShell.spec.tsx +++ b/ui/src/pages/ChatPageShell.spec.tsx @@ -56,17 +56,19 @@ describe('ChatPageShell', () => { it('keeps Workspace view controls out of the title bar', () => { render(
Chat content
) - expect(screen.getByTestId('harness-sidebar')).toBeTruthy() + expect(screen.queryByTestId('harness-sidebar')).toBeNull() expect(screen.queryByRole('button', { name: 'Collapse Ask Alice' })).toBeNull() - expect(screen.getByRole('separator', { name: 'Resize Ask Alice' })).toBeTruthy() + expect(screen.queryByRole('separator')).toBeNull() + expect(screen.getByText('Chat content')).toBeTruthy() expect(screen.queryByRole('group', { name: 'Workspace display mode' })).toBeNull() }) - it('reuses the Ask Alice shell chrome for a ready AutoQuant desk', () => { + it('gives a ready AutoQuant desk the content surface without a second navigator', () => { render(
Quant content
) expect(screen.queryByRole('button', { name: 'Collapse Quant' })).toBeNull() - expect(screen.getByRole('separator', { name: 'Resize Quant' })).toBeTruthy() - expect(screen.getByTestId('harness-sidebar')).toBeTruthy() + expect(screen.queryByRole('separator')).toBeNull() + expect(screen.queryByTestId('harness-sidebar')).toBeNull() + expect(screen.getByText('Quant content')).toBeTruthy() }) it('keeps AutoQuant navigation hidden until a default desk is ready', () => { diff --git a/ui/src/pages/ChatPageShell.tsx b/ui/src/pages/ChatPageShell.tsx index 583ac1747..cbcdf7c40 100644 --- a/ui/src/pages/ChatPageShell.tsx +++ b/ui/src/pages/ChatPageShell.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { PageSidebarLayout } from '../components/PageSidebarLayout' -import { ChatChannelListContainer } from '../components/ChatChannelListContainer' -import { useWorkspaces } from '../contexts/workspaces-context' +import { PageContentLayout } from '../components/PageTopBar' export type HarnessSidebarMode = 'chat' | 'auto-quant' | 'prediction' @@ -12,62 +10,13 @@ interface ChatPageShellProps { } export function ChatPageShell({ children, mode = 'chat' }: ChatPageShellProps) { - if (mode === 'auto-quant') { - return {children} - } - if (mode === 'prediction') { - return {children} - } - return {children} -} - -function AutoPredictionReadyShell({ children }: { children: ReactNode }) { - const ctx = useWorkspaces() - const ready = ctx.autoPredictionPreferenceLoaded - && ctx.hasLoaded - && ctx.workspaces.some((workspace) => - workspace.id === ctx.autoPredictionDefaultWorkspaceId - && workspace.template === 'auto-prediction') - if (!ready) return <>{children} - return {children} -} - -function AutoQuantReadyShell({ children }: { children: ReactNode }) { - const ctx = useWorkspaces() - const ready = ctx.autoQuantPreferenceLoaded - && ctx.hasLoaded - && ctx.workspaces.some((workspace) => - workspace.id === ctx.autoQuantDefaultWorkspaceId - && workspace.template === 'auto-quant-v2') - - // AutoQuant owns an explicit readiness boundary: initialization or a - // deliberate existing-desk choice must establish the default before any - // research navigation is exposed. The shared chrome begins only afterward. - if (!ready) return <>{children} - return {children} -} - -function HarnessPageShell({ children, mode }: { children: ReactNode; mode: HarnessSidebarMode }) { const { t } = useTranslation() - + // Sessions live in the global rail. Landing pages still own readiness; + // their working surfaces no longer mount a second conversation navigator. return ( - <> - ( - - )} - > - {children} - - - + + {children} + ) } diff --git a/ui/src/pages/QuickStartPage.spec.tsx b/ui/src/pages/QuickStartPage.spec.tsx new file mode 100644 index 000000000..a904a6f69 --- /dev/null +++ b/ui/src/pages/QuickStartPage.spec.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, it, vi } from 'vitest' +import { QuickStartPage } from './QuickStartPage' +import '../i18n' + +vi.mock('./ChatLandingPage', () => { + const page = (name: string) => ({ spec, onPromptChange, showHeader }: { + spec: { params: { initialPrompt?: string } }; onPromptChange: (value: string) => void; showHeader: boolean + }) =>
+ {name} shared landing + {showHeader ? 'Duplicate header' : 'Shared Quick Start header'} +