Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions docs/ui-interaction-and-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 (`/<harness>/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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 1 addition & 4 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 ? (
<PrimaryNavigationToggle ref={toggleFocus.ref} collapsed={railCollapsed} onToggle={() => {
Expand Down
56 changes: 43 additions & 13 deletions ui/src/components/ActivityBar.current-state.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 }) => <div data-testid={`harness-${mode}`} />,
}))

vi.mock('../live/trading-push', () => ({
usePendingPushCount: () => 0,
}))
Expand All @@ -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',
Expand All @@ -60,13 +66,19 @@ vi.mock('react-i18next', () => ({
}))

vi.mock('./ActivityBarUtilityMenu', () => ({
ActivityBarUtilityMenu: ({ onOpenSettings }: { onOpenSettings: () => void }) => (
<button type="button" data-testid="activity-bar-utility" onClick={onOpenSettings}>Utilities</button>
ActivityBarUtilityMenu: ({ onOpenSettings, onOpenConnectors, connectorWarnings, connectorsActive }: {
onOpenSettings: () => void; onOpenConnectors: () => void; connectorWarnings: number; connectorsActive: boolean
}) => (
<div data-testid="activity-bar-utility-state" data-warnings={connectorWarnings} data-active={connectorsActive}>
<button type="button" data-testid="activity-bar-utility" onClick={onOpenSettings}>Utilities</button>
<button type="button" onClick={onOpenConnectors}>Open Connectors</button>
</div>
),
}))

beforeEach(() => {
mocks.selectedSidebar = 'issue'
mocks.focusedKind = 'issue'
mocks.connectorWarnings = 0
mocks.railCollapsed = false
vi.stubGlobal('matchMedia', vi.fn(() => ({
Expand Down Expand Up @@ -94,27 +106,45 @@ describe('ActivityBar current destination', () => {
const { rerender } = render(<ActivityBar open onClose={vi.fn()} />)

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(<ActivityBar open onClose={vi.fn()} />)

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(<ActivityBar open onClose={vi.fn()} />)
mocks.selectedSidebar = 'connectors'
const onClose = vi.fn()
render(<ActivityBar open onClose={onClose} />)
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(<ActivityBar open onClose={vi.fn()} />)
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', () => {
Expand All @@ -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(<ActivityBar open onClose={vi.fn()} />)
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()
})

Expand Down
21 changes: 13 additions & 8 deletions ui/src/components/ActivityBar.drawer-state.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <button className="min-h-10 md:min-h-8">{mode} Harness</button>,
}))

vi.mock('../live/trading-push', () => ({
usePendingPushCount: () => 0,
}))
Expand All @@ -35,7 +40,7 @@ vi.mock('../live/connector-health', () => ({

vi.mock('../live/activity-bar-collapse', () => ({
useActivityBarCollapse: (selector: (state: Record<string, unknown>) => unknown) => selector({
collapsedSections: {},
collapsedSections: { beta: true },
setCollapsed: mocks.setCollapsed,
railCollapsed: false,
setRailCollapsed: mocks.setRailCollapsed,
Expand All @@ -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',
Expand Down Expand Up @@ -104,14 +109,14 @@ describe('ActivityBar mobile drawer state', () => {
it('keeps mobile drawer actions tappable without changing desktop density', () => {
render(<ActivityBar open onClose={vi.fn()} desktopStatic={false} />)

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()
})

Expand Down Expand Up @@ -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')
Expand Down
Loading