From d8f2fcf29cf55511ddfafa0d2c1c57d96b66f3d1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:54:06 -0400 Subject: [PATCH 1/4] feat(activity): surface active work across the session picker, tool rail and mobile --- frontend/src/api/types/settings.ts | 4 +- .../components/activity/ActivityCountPill.tsx | 25 + .../components/activity/ActivityList.test.tsx | 346 +++++++ .../src/components/activity/ActivityList.tsx | 231 +++++ .../activity/CompactActivityList.tsx | 20 + .../activity/RepoActivityIndicator.tsx | 23 + .../navigation/DesktopSessionTree.test.tsx | 4 + .../navigation/DesktopSessionTree.tsx | 1 + .../components/navigation/MoreDrawer.test.tsx | 95 ++ .../src/components/navigation/MoreDrawer.tsx | 9 + .../navigation/RepoQuickSwitchSheet.test.tsx | 113 +++ .../navigation/RepoQuickSwitchSheet.tsx | 4 + .../components/navigation/RepoSessionNav.tsx | 11 +- .../navigation/ToolSidePanel.test.tsx | 115 ++- .../components/navigation/ToolSidePanel.tsx | 78 +- .../navigation/moreDrawerItems.test.ts | 16 +- .../components/navigation/moreDrawerItems.ts | 5 +- .../navigation/sidebar-session-tree.ts | 16 + .../src/components/repo/RepoCard.test.tsx | 36 + frontend/src/components/repo/RepoCard.tsx | 18 +- .../session/ProjectPickerContent.tsx | 2 +- .../session/SessionPickerDialog.test.tsx | 153 +++- .../session/SessionPickerDialog.tsx | 859 +++++++++++++----- .../settings/ActivitySettings.test.tsx | 64 ++ .../components/settings/ActivitySettings.tsx | 40 + .../components/settings/GeneralSettings.tsx | 3 + .../components/ui/activity-spinner.test.tsx | 144 +++ .../src/components/ui/activity-spinner.tsx | 96 ++ .../ui/session-status-indicator.test.tsx | 12 + .../ui/session-status-indicator.tsx | 40 +- frontend/src/contexts/EventContext.test.tsx | 20 +- frontend/src/contexts/EventContext.tsx | 56 +- frontend/src/hooks/useActiveWork.ts | 164 ++++ frontend/src/hooks/useToolPanel.test.ts | 30 +- frontend/src/hooks/useToolPanel.ts | 4 +- frontend/src/lib/activeWork.test.ts | 296 ++++++ frontend/src/lib/activeWork.ts | 191 ++++ .../AssistantRedirect.preview.test.tsx | 5 + .../RepoDetail.schedule-sessions.test.tsx | 4 + .../RepoDetail.worktree-setup.test.tsx | 4 + .../SessionDetail.assistant-loading.test.tsx | 5 + .../SessionDetail.form-prompt.test.tsx | 5 + .../SessionDetail.scroll-floating.test.tsx | 5 + frontend/src/stores/sessionStatusStore.ts | 16 + shared/src/schemas/internal-assistant.ts | 1 + shared/src/schemas/settings.ts | 6 + 46 files changed, 3119 insertions(+), 276 deletions(-) create mode 100644 frontend/src/components/activity/ActivityCountPill.tsx create mode 100644 frontend/src/components/activity/ActivityList.test.tsx create mode 100644 frontend/src/components/activity/ActivityList.tsx create mode 100644 frontend/src/components/activity/CompactActivityList.tsx create mode 100644 frontend/src/components/activity/RepoActivityIndicator.tsx create mode 100644 frontend/src/components/settings/ActivitySettings.test.tsx create mode 100644 frontend/src/components/settings/ActivitySettings.tsx create mode 100644 frontend/src/components/ui/activity-spinner.test.tsx create mode 100644 frontend/src/components/ui/activity-spinner.tsx create mode 100644 frontend/src/hooks/useActiveWork.ts create mode 100644 frontend/src/lib/activeWork.test.ts create mode 100644 frontend/src/lib/activeWork.ts diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 302b5d372..dd20feaa8 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -6,6 +6,8 @@ import { DEFAULT_LEADER_KEY, DEFAULT_DIRECT_SHORTCUTS, BLOCKED_SERVER_ENV_KEYS, + DEFAULT_ACTIVITY_WINDOW_MINUTES, + ACTIVITY_WINDOW_MINUTE_OPTIONS, selectPreferredOpenCodeConfigSourceName, type TTSConfig, type STTConfig, @@ -29,7 +31,7 @@ import { saveFile } from '@/lib/download' export type { TTSConfig, STTConfig, OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName, UpdateOpenCodeConfigRequest, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity, GitIdentityProfile, KeyboardShortcutAction } from '@opencode-manager/shared' -export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, DEFAULT_DIRECT_SHORTCUTS, BLOCKED_SERVER_ENV_KEYS } +export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, DEFAULT_DIRECT_SHORTCUTS, BLOCKED_SERVER_ENV_KEYS, DEFAULT_ACTIVITY_WINDOW_MINUTES, ACTIVITY_WINDOW_MINUTE_OPTIONS } export { isOpenCodeConfigSourceName } from '@opencode-manager/shared' export function getOpenCodeConfigSources(config: OpenCodeConfigFile): OpenCodeConfigSourceFile[] { diff --git a/frontend/src/components/activity/ActivityCountPill.tsx b/frontend/src/components/activity/ActivityCountPill.tsx new file mode 100644 index 000000000..25215a302 --- /dev/null +++ b/frontend/src/components/activity/ActivityCountPill.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils' + +interface ActivityCountPillProps { + needsInput: number + running: number + className?: string +} + +export function ActivityCountPill({ needsInput, running, className }: ActivityCountPillProps) { + const total = needsInput + running + if (total === 0) return null + + return ( + + ) +} diff --git a/frontend/src/components/activity/ActivityList.test.tsx b/frontend/src/components/activity/ActivityList.test.tsx new file mode 100644 index 000000000..a70bf87aa --- /dev/null +++ b/frontend/src/components/activity/ActivityList.test.tsx @@ -0,0 +1,346 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import type { Repo, Session } from '@/api/types' +import type { ActiveWorkItem } from '@/lib/activeWork' +import { formatShortRelativeTime } from '@/lib/utils' +import { ActivityList } from './ActivityList' + +const mocks = vi.hoisted(() => ({ + useActiveWork: vi.fn(), +})) + +vi.mock('@/hooks/useActiveWork', () => ({ + useActiveWork: mocks.useActiveWork, +})) + +const WINDOW_MS = 30 * 60_000 +const NOW = Date.now() + +function createSession(id: string, title: string, updated = NOW - 60_000): Session { + return { + id, + projectID: 'proj-1', + title, + time: { created: updated - 10000, updated }, + location: { directory: `/repos/${id}` }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function createRepo(id: number, name: string): Repo { + return { + id, + name, + fullPath: `/repos/${id}`, + localPath: `/repos/${id}`, + defaultBranch: 'main', + currentBranch: 'feature/x', + cloneStatus: 'ready', + clonedAt: 0, + } +} + +function createItem( + overrides: Partial & Pick, +): ActiveWorkItem { + return { + key: `${overrides.repo.id}:${overrides.session.id}`, + path: `/repos/${overrides.repo.id}/sessions/${overrides.session.id}`, + ...overrides, + } +} + +function setGroups(groups: { + needsInput?: ActiveWorkItem[] + running?: ActiveWorkItem[] + recent?: ActiveWorkItem[] +}) { + mocks.useActiveWork.mockReturnValue({ + groups: { + needsInput: groups.needsInput ?? [], + running: groups.running ?? [], + recent: groups.recent ?? [], + }, + isLoading: false, + isError: false, + }) +} + +const renderList = ( + onNavigate = vi.fn(), + path = '/', + props: { hideWhenEmpty?: boolean; title?: string; variant?: 'full' | 'compact' } = {}, +) => + render( + + + , + ) + +describe('ActivityList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders non-empty groups in needs-input, running, recent order', () => { + setGroups({ + needsInput: [createItem({ state: 'needs-input', session: createSession('n1', 'Needs'), repo: createRepo(1, 'Alpha') })], + running: [createItem({ state: 'running', session: createSession('r1', 'Running'), repo: createRepo(2, 'Beta') })], + recent: [createItem({ state: 'recent', session: createSession('c1', 'Recent'), repo: createRepo(3, 'Gamma') })], + }) + + renderList() + + const headings = screen.getAllByRole('heading').map((node) => node.textContent) + expect(headings).toEqual(['Needs input', 'Running', 'Recent']) + }) + + it('hides empty groups', () => { + setGroups({ + running: [createItem({ state: 'running', session: createSession('r1', 'Running'), repo: createRepo(2, 'Beta') })], + }) + + renderList() + + const headings = screen.getAllByRole('heading').map((node) => node.textContent) + expect(headings).toEqual(['Running']) + expect(screen.queryByText('Needs input')).not.toBeInTheDocument() + expect(screen.queryByText('Recent')).not.toBeInTheDocument() + }) + + it('renders the empty state with the window minutes', () => { + setGroups({}) + + renderList() + + expect(screen.getByText('No active or recent sessions in the last 30 minutes')).toBeInTheDocument() + }) + + it('calls onNavigate with the item when a row is clicked', () => { + const onNavigate = vi.fn() + const item = createItem({ state: 'recent', session: createSession('c1', 'Recent'), repo: createRepo(3, 'Gamma') }) + setGroups({ recent: [item] }) + + renderList(onNavigate) + + fireEvent.click(screen.getByRole('button', { name: /Recent/ })) + + expect(onNavigate).toHaveBeenCalledWith(item) + }) + + it('marks the currently open session with aria-current', () => { + setGroups({ + recent: [ + createItem({ state: 'recent', session: createSession('open', 'Open session'), repo: createRepo(7, 'Gamma') }), + createItem({ state: 'recent', session: createSession('other', 'Other session'), repo: createRepo(7, 'Gamma') }), + ], + }) + + renderList(vi.fn(), '/repos/7/sessions/open') + + expect(screen.getByRole('button', { name: /Open session/ })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: /Other session/ })).not.toHaveAttribute('aria-current') + }) + + it('shows a loading state', () => { + mocks.useActiveWork.mockReturnValue({ + groups: { needsInput: [], running: [], recent: [] }, + isLoading: true, + isError: false, + }) + + renderList() + + expect(screen.getByText('Loading activity...')).toBeInTheDocument() + }) + + it('shows an error state', () => { + mocks.useActiveWork.mockReturnValue({ + groups: { needsInput: [], running: [], recent: [] }, + isLoading: false, + isError: true, + }) + + renderList() + + expect(screen.getByText("Couldn't load activity.")).toBeInTheDocument() + }) + + it('renders nothing when hideWhenEmpty and there are no items', () => { + setGroups({}) + + const { container } = renderList(vi.fn(), '/', { hideWhenEmpty: true }) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing while loading when hideWhenEmpty', () => { + mocks.useActiveWork.mockReturnValue({ + groups: { needsInput: [], running: [], recent: [] }, + isLoading: true, + isError: false, + }) + + const { container } = renderList(vi.fn(), '/', { hideWhenEmpty: true }) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders the title only when there are items', () => { + setGroups({ + recent: [createItem({ state: 'recent', session: createSession('c1', 'Recent'), repo: createRepo(3, 'Gamma') })], + }) + + renderList(vi.fn(), '/', { title: 'Activity' }) + + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument() + }) + + it('does not render the title when there are no items', () => { + setGroups({}) + + renderList(vi.fn(), '/', { title: 'Activity' }) + + expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() + }) +}) + +describe('ActivityList compact variant', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders only the section title, without group headings', () => { + setGroups({ + needsInput: [ + createItem({ state: 'needs-input', session: createSession('n1', 'Needs work'), repo: createRepo(1, 'Alpha') }), + ], + running: [ + createItem({ state: 'running', session: createSession('r1', 'Running task'), repo: createRepo(2, 'Beta') }), + ], + recent: [ + createItem({ state: 'recent', session: createSession('c1', 'Recent task'), repo: createRepo(3, 'Gamma') }), + ], + }) + + renderList(vi.fn(), '/', { variant: 'compact', title: 'Activity' }) + + expect(screen.getAllByRole('heading').map((node) => node.textContent)).toEqual(['Activity']) + expect(screen.queryByText('Needs input')).toBeNull() + expect(screen.queryByText('Running')).toBeNull() + expect(screen.queryByText('Recent')).toBeNull() + }) + + it('renders one single-line row per item in needs-input, running, recent order', () => { + const needs = createItem({ + state: 'needs-input', + session: createSession('n1', 'Needs work'), + repo: createRepo(1, 'Alpha'), + }) + const running = createItem({ + state: 'running', + session: createSession('r1', 'Running task'), + repo: createRepo(2, 'Beta'), + }) + const recent = createItem({ + state: 'recent', + session: createSession('c1', 'Recent task'), + repo: createRepo(3, 'Gamma'), + }) + setGroups({ needsInput: [needs], running: [running], recent: [recent] }) + + renderList(vi.fn(), '/', { variant: 'compact' }) + + const rows = screen.getAllByRole('button') + expect(rows).toHaveLength(3) + expect(rows[0]).toHaveTextContent('Needs work') + expect(rows[1]).toHaveTextContent('Running task') + expect(rows[2]).toHaveTextContent('Recent task') + + expect(rows[0]).toHaveAccessibleName(/Needs work/) + expect(rows[0]).toHaveAccessibleName(/Alpha/) + expect(rows[0]).toHaveTextContent(formatShortRelativeTime(new Date(needs.session.time.updated))) + expect(rows[0]).not.toHaveTextContent('feature/x') + }) + + it('caps the compact list at five items, keeping needs-input and running over recent', () => { + setGroups({ + needsInput: [1, 2].map((n): ActiveWorkItem => + createItem({ + state: 'needs-input', + session: createSession(`n${n}`, `Needs work ${n}`), + repo: createRepo(n, `Repo ${n}`), + }), + ), + running: [1, 2, 3].map((n): ActiveWorkItem => + createItem({ + state: 'running', + session: createSession(`r${n}`, `Running task ${n}`), + repo: createRepo(10 + n, `Repo ${10 + n}`), + }), + ), + recent: [1, 2, 3].map((n): ActiveWorkItem => + createItem({ + state: 'recent', + session: createSession(`c${n}`, `Recent task ${n}`), + repo: createRepo(20 + n, `Repo ${20 + n}`), + }), + ), + }) + + renderList(vi.fn(), '/', { variant: 'compact' }) + + const rows = screen.getAllByRole('button') + expect(rows).toHaveLength(5) + expect(rows[0]).toHaveTextContent('Needs work 1') + expect(rows[4]).toHaveTextContent('Running task 3') + expect(screen.queryByText('Recent task 1')).toBeNull() + }) + + it('fills the remaining compact slots with recent items', () => { + setGroups({ + running: [ + createItem({ state: 'running', session: createSession('r1', 'Running task'), repo: createRepo(1, 'Alpha') }), + ], + recent: [1, 2, 3, 4, 5].map((n): ActiveWorkItem => + createItem({ + state: 'recent', + session: createSession(`c${n}`, `Recent task ${n}`), + repo: createRepo(10 + n, `Repo ${10 + n}`), + }), + ), + }) + + renderList(vi.fn(), '/', { variant: 'compact' }) + + const rows = screen.getAllByRole('button') + expect(rows).toHaveLength(5) + expect(rows[0]).toHaveTextContent('Running task') + expect(rows[4]).toHaveTextContent('Recent task 4') + expect(screen.queryByText('Recent task 5')).toBeNull() + }) + + it('marks the currently open session with aria-current', () => { + setGroups({ + recent: [ + createItem({ state: 'recent', session: createSession('open', 'Open session'), repo: createRepo(7, 'Gamma') }), + createItem({ state: 'recent', session: createSession('other', 'Other session'), repo: createRepo(7, 'Gamma') }), + ], + }) + + renderList(vi.fn(), '/repos/7/sessions/open', { variant: 'compact' }) + + expect(screen.getByRole('button', { name: /Open session/ })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: /Other session/ })).not.toHaveAttribute('aria-current') + }) + + it('renders nothing when hideWhenEmpty and there are no items', () => { + setGroups({}) + + const { container } = renderList(vi.fn(), '/', { hideWhenEmpty: true, variant: 'compact' }) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/activity/ActivityList.tsx b/frontend/src/components/activity/ActivityList.tsx new file mode 100644 index 000000000..916ac61a9 --- /dev/null +++ b/frontend/src/components/activity/ActivityList.tsx @@ -0,0 +1,231 @@ +import { useLocation } from 'react-router-dom' +import { CircleAlert, CircleCheck, CircleX } from 'lucide-react' +import { useActiveWork } from '@/hooks/useActiveWork' +import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { parseRepoRoute } from '@/lib/navigation' +import { cn, formatShortRelativeTime, getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' +import type { ActiveWorkItem } from '@/lib/activeWork' + +const UNTITLED_SESSION_TITLE = 'Untitled Session' +const MS_PER_MINUTE = 60_000 +const COMPACT_MAX_ITEMS = 5 + +const FULL_ICON_CLASS = 'h-4 w-4' +const COMPACT_ICON_CLASS = 'h-3.5 w-3.5' + +interface ActivityListProps { + windowMs: number + /** Called when a row is chosen; the caller owns navigation so each surface can apply its own history and close rules. */ + onNavigate: (item: ActiveWorkItem) => void + hideWhenEmpty?: boolean + title?: string + /** `compact` renders one flat, single-line list capped at {@link COMPACT_MAX_ITEMS}; `full` renders grouped rows. */ + variant?: 'full' | 'compact' +} + +function isCurrentItem(item: ActiveWorkItem, sessionID: string | null, repoID: number | null) { + return item.repo.id === repoID && item.session.id === sessionID +} + +function activityRowClass(isCurrent: boolean, compact: boolean) { + return cn( + 'flex w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + compact ? 'py-1' : 'py-1.5', + isCurrent && 'bg-accent', + ) +} + +function ActivityStateIcon({ item, iconClassName }: { item: ActiveWorkItem; iconClassName: string }) { + if (item.state === 'needs-input') { + return