From eb8cc29d89f440ac0a4aeb3d5d93ae30703bbe95 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:59:55 -0700 Subject: [PATCH 1/2] feat(v2): rebuild workspace inspector --- frontend/.eslintrc.js | 2 +- frontend/src/i18n/locales/en.json | 19 + frontend/src/i18n/locales/zh-CN.json | 19 + .../src/v2/__tests__/V2FirstRunHero.test.tsx | 2 +- .../src/v2/__tests__/V2Inspector.test.tsx | 101 + .../src/v2/__tests__/V2InviteDmGate.test.tsx | 2 +- .../v2/__tests__/V2LayoutSelection.test.tsx | 2 +- .../V2PodInspectorAgentRoom.test.tsx | 49 - .../V2PodInspectorMemberProfile.test.tsx | 98 - .../V2PodInspectorScopedManager.test.tsx | 71 - .../v2/__tests__/v2-layout-invariants.test.ts | 61 +- frontend/src/v2/components/V2Inspector.tsx | 213 ++ frontend/src/v2/components/V2Layout.tsx | 85 +- frontend/src/v2/components/V2PodChat.tsx | 4 +- frontend/src/v2/components/V2PodInspector.tsx | 1961 ----------------- frontend/src/v2/utils/agentKey.ts | 4 +- frontend/src/v2/v2.css | 226 +- 17 files changed, 655 insertions(+), 2264 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2Inspector.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2PodInspectorAgentRoom.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2PodInspectorMemberProfile.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2PodInspectorScopedManager.test.tsx create mode 100644 frontend/src/v2/components/V2Inspector.tsx delete mode 100644 frontend/src/v2/components/V2PodInspector.tsx diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 94180fdd61..6ba93a0bb4 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -62,7 +62,7 @@ module.exports = { 'src/v2/components/V2AdminUsers.tsx', 'src/v2/components/V2AgentBYO.tsx', 'src/v2/components/V2FeedbackMenu.tsx', - 'src/v2/components/V2PodInspector.tsx', + 'src/v2/components/V2Inspector.tsx', 'src/v2/components/V2PodsSidebar.tsx', 'src/v2/components/V2YourTeamPage.tsx', ], diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bc820c3cef..079b200f2e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -957,6 +957,25 @@ "stat_one": "{{complete}} of {{total}} task complete", "stat_other": "{{complete}} of {{total}} tasks complete" }, + "workspace": { + "ariaLabel": "Pod inspector", + "close": "Close inspector", + "agentsIn": "agents in {{pod}}", + "noAgents": "no agents yet", + "needsYou": "needs you", + "nothingOpen": "nothing open", + "boardToday": "board · today", + "boardCounts": "{{open}} open · {{inProgress}} in progress · {{done}} done", + "wip": "wip", + "members": "members", + "manage": "manage", + "agentStatus": { + "needsYou": "needs you · {{title}}", + "workingTask": "working · {{title}}", + "working": "working", + "idle": "idle" + } + }, "goal": { "title": "Goal", "empty": "No goal set yet." diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 766aa6f081..d075fdce65 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -954,6 +954,25 @@ "stat": "{{total}} 个任务中已完成 {{complete}} 个", "stat_other": "{{total}} 个任务中已完成 {{complete}} 个" }, + "workspace": { + "ariaLabel": "Pod 检视器", + "close": "关闭检视器", + "agentsIn": "{{pod}} 中的智能体", + "noAgents": "暂无智能体", + "needsYou": "需要你处理", + "nothingOpen": "没有待处理项", + "boardToday": "看板 · 今天", + "boardCounts": "{{open}} 开放 · {{inProgress}} 进行中 · {{done}} 完成", + "wip": "进行中", + "members": "成员", + "manage": "管理", + "agentStatus": { + "needsYou": "需要你处理 · {{title}}", + "workingTask": "进行中 · {{title}}", + "working": "进行中", + "idle": "空闲" + } + }, "goal": { "title": "目标", "empty": "尚未设定目标。" diff --git a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx index 86921a01f5..1b8e61011f 100644 --- a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx +++ b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx @@ -71,7 +71,7 @@ jest.mock('../components/V2NavRail', () => () => ( )); jest.mock('../components/V2PodsSidebar', () => () => ); -jest.mock('../components/V2PodInspector', () => () => ); +jest.mock('../components/V2Inspector', () => () => ); jest.mock('../components/V2InviteModal', () => () => null); jest.mock('../components/V2PodChat', () => ({ firstRunVisible }: { firstRunVisible?: boolean }) => (
diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx new file mode 100644 index 0000000000..9923893685 --- /dev/null +++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx @@ -0,0 +1,101 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2Inspector from '../components/V2Inspector'; + +const mockNavigate = jest.fn(); +const mockGet = jest.fn(); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +jest.mock('../hooks/useV2Api', () => ({ + useV2Api: () => ({ get: mockGet }), +})); + +jest.mock('../components/V2Avatar', () => ({ name }: { name: string }) => {name}); + +const detail = { + pod: { _id: 'pod-1', name: 'Sharpen', type: 'team' }, + members: [], + agents: [ + { agentName: 'wren', instanceId: 'default', displayName: 'Wren', status: 'working' }, + { agentName: 'kai', instanceId: 'default', displayName: 'Kai' }, + ], + messages: [], loading: false, error: null, sendError: null, + hasMore: false, loadingOlder: false, loadOlder: jest.fn(), refresh: jest.fn(), sendMessage: jest.fn(), +}; + +const renderInspector = (props: Partial> = {}) => render( + + + , +); + +describe('V2Inspector', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockImplementation((url: string) => { + if (url === '/api/activity/decision-queue') { + return Promise.resolve({ items: [{ + id: 'decision-1', kind: 'decision', title: 'Slack default mode', actorName: 'Wren', podId: 'pod-1', messageId: 'message-7', + }] }); + } + if (url === '/api/v1/tasks/pod-1') { + return Promise.resolve({ tasks: [ + { taskId: 'TASK-131', title: 'Build the card', status: 'in_progress', assignee: 'kai' }, + { taskId: 'TASK-132', title: 'Verify the card', status: 'pending' }, + { taskId: 'TASK-130', title: 'Ship the event', status: 'done' }, + ] }); + } + return Promise.resolve({}); + }); + }); + + test('renders the three artboard cards from the pod’s existing data', async () => { + renderInspector(); + + expect(screen.getByRole('heading', { name: 'agents in sharpen' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'needs you' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'board · today' })).toBeInTheDocument(); + expect(await screen.findByText('Slack default mode')).toBeInTheDocument(); + expect(screen.getByText('1 open · 1 in progress · 1 done')).toBeInTheDocument(); + expect(screen.getByText('needs you · Slack default mode')).toBeInTheDocument(); + expect(screen.getByText('working · Build the card')).toBeInTheDocument(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + }); + + test('links attention, board, profile, invite, and manage exits without legacy tabs', async () => { + const onOpenInvite = jest.fn(); + renderInspector({ onOpenInvite }); + + fireEvent.click(await screen.findByRole('button', { name: 'Slack default mode Wren' })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/pods/pod-1#message-message-7'); + + fireEvent.click(screen.getByRole('button', { name: '1 open · 1 in progress · 1 done' })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/pods/pod-1/board'); + + fireEvent.click(screen.getByRole('button', { name: /Wren needs you/ })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/agent/wren/default'); + + fireEvent.click(screen.getByRole('button', { name: 'members' })); + expect(onOpenInvite).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'manage' })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/manage?podId=pod-1'); + }); + + test('does not render a stale attention item from another pod', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/api/activity/decision-queue') return Promise.resolve({ items: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] }); + return Promise.resolve({ tasks: [] }); + }); + renderInspector(); + await waitFor(() => expect(screen.getByText('nothing open')).toBeInTheDocument()); + expect(screen.queryByText('Other pod')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx b/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx index 45651dbfaf..1de2c32201 100644 --- a/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx +++ b/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx @@ -37,7 +37,7 @@ jest.mock('../../context/AuthContext', () => ({ jest.mock('../components/V2NavRail', () => () => null); jest.mock('../components/V2PodsSidebar', () => () => null); -jest.mock('../components/V2PodInspector', () => () => null); +jest.mock('../components/V2Inspector', () => () => null); jest.mock('../components/V2InviteModal', () => function MockV2InviteModal({ open, initialTab }) { return open ?
{initialTab}
: null; }); diff --git a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx index b4a3a9670b..dea9bf4869 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -39,7 +39,7 @@ jest.mock('../../context/AuthContext', () => ({ jest.mock('../components/V2NavRail', () => () => null); jest.mock('../components/V2PodsSidebar', () => () => null); jest.mock('../components/V2PodChat', () => () => null); -jest.mock('../components/V2PodInspector', () => () => null); +jest.mock('../components/V2Inspector', () => () => null); jest.mock('../components/V2InviteModal', () => () => null); jest.mock('../components/V2FirstRunHero', () => () => null); diff --git a/frontend/src/v2/__tests__/V2PodInspectorAgentRoom.test.tsx b/frontend/src/v2/__tests__/V2PodInspectorAgentRoom.test.tsx deleted file mode 100644 index 31370e0e94..0000000000 --- a/frontend/src/v2/__tests__/V2PodInspectorAgentRoom.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import V2PodInspector from '../components/V2PodInspector'; - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); -jest.mock('../hooks/useV2Api', () => ({ - useV2Api: () => ({ get: jest.fn().mockResolvedValue({}), post: jest.fn(), patch: jest.fn(), del: jest.fn() }), -})); -jest.mock('../../context/AuthContext', () => ({ - useAuth: () => ({ currentUser: { _id: 'human-1', username: 'sam' } }), -})); -jest.mock('../../context/SocketContext', () => ({ - useSocket: () => ({ socket: null, connected: false }), -})); -jest.mock('../components/V2Avatar', () => () => ); - -const detail = { - pod: { _id: 'room-1', name: 'Scout', type: 'agent-room', createdBy: { _id: 'human-1' } }, - members: [ - { _id: 'human-1', username: 'sam', isBot: false }, - { _id: 'agent-1', username: 'scout', isBot: true }, - ], - agents: [], messages: [], loading: false, error: null, sendError: null, - hasMore: false, loadingOlder: false, loadOlder: jest.fn(), refresh: jest.fn(), sendMessage: jest.fn(), -}; - -describe('V2PodInspector agent room', () => { - it('shows both 1:1 participants without exposing pod membership controls', () => { - render( - - - , - ); - - expect(screen.getByTestId('inspector-room-participants')).toHaveTextContent('sam'); - expect(screen.getByTestId('inspector-room-participants')).toHaveTextContent('scout'); - expect(screen.queryByRole('tab', { name: 'inspector.tabs.members' })).toBeNull(); - }); -}); diff --git a/frontend/src/v2/__tests__/V2PodInspectorMemberProfile.test.tsx b/frontend/src/v2/__tests__/V2PodInspectorMemberProfile.test.tsx deleted file mode 100644 index 1e2e6becea..0000000000 --- a/frontend/src/v2/__tests__/V2PodInspectorMemberProfile.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import V2PodInspector from '../components/V2PodInspector'; - -const mockNavigate = jest.fn(); - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -jest.mock('../hooks/useV2Api', () => ({ - useV2Api: () => ({ - get: jest.fn().mockResolvedValue({}), - post: jest.fn(), patch: jest.fn(), del: jest.fn(), - }), -})); - -jest.mock('../../context/AuthContext', () => ({ - useAuth: () => ({ currentUser: { _id: 'human-1', username: 'sam' } }), -})); - -jest.mock('../../context/SocketContext', () => ({ - useSocket: () => ({ socket: null, connected: false }), -})); - -jest.mock('../components/V2Avatar', () => () => ); - -const detail = { - pod: { _id: 'pod-1', name: 'Workspace', type: 'team' }, - members: [], - agents: [{ - agentName: 'scout/ops', - instanceId: 'blue sky', - displayName: 'Scout', - runtime: { runtimeType: 'webhook' }, - }], - messages: [], - loading: false, - error: null, - sendError: null, - hasMore: false, - loadingOlder: false, - loadOlder: jest.fn(), - refresh: jest.fn(), - sendMessage: jest.fn(), -}; - -const detailWithoutAgentName = { - ...detail, - agents: [{ ...detail.agents[0], agentName: '' }], -}; - -describe('V2PodInspector member profile action', () => { - beforeEach(() => jest.clearAllMocks()); - - test('opens the selected agent profile instead of the fleet manager', () => { - render( - - - , - ); - - expect(screen.getByText('yourTeam.card.profile')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'yourTeam.card.viewProfileAria' })); - - expect(mockNavigate).toHaveBeenCalledWith('/v2/agent/scout%2Fops/blue%20sky'); - }); - - test('does not construct a route when a malformed registry row lacks an agent name', () => { - render( - - - , - ); - - expect(screen.getByRole('button', { name: 'yourTeam.card.viewProfileAria' })).toBeDisabled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/v2/__tests__/V2PodInspectorScopedManager.test.tsx b/frontend/src/v2/__tests__/V2PodInspectorScopedManager.test.tsx deleted file mode 100644 index 6b32e035a3..0000000000 --- a/frontend/src/v2/__tests__/V2PodInspectorScopedManager.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import V2PodInspector from '../components/V2PodInspector'; - -const mockNavigate = jest.fn(); - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -jest.mock('../hooks/useV2Api', () => ({ - useV2Api: () => ({ - get: jest.fn().mockResolvedValue({}), - post: jest.fn(), patch: jest.fn(), del: jest.fn(), - }), -})); - -jest.mock('../../context/AuthContext', () => ({ - useAuth: () => ({ currentUser: { _id: 'human-1', username: 'sam' } }), -})); - -jest.mock('../../context/SocketContext', () => ({ - useSocket: () => ({ socket: null, connected: false }), -})); - -jest.mock('../components/V2Avatar', () => () => ); - -const detail = { - pod: { _id: 'pod-1', name: 'Workspace', type: 'team' }, - members: [], - agents: [], - messages: [], - loading: false, - error: null, - sendError: null, - hasMore: false, - loadingOlder: false, - loadOlder: jest.fn(), - refresh: jest.fn(), - sendMessage: jest.fn(), -}; - -describe('V2PodInspector scoped manager action', () => { - beforeEach(() => jest.clearAllMocks()); - - test('opens the agent manager with the current pod preselected', () => { - render( - - - , - ); - - fireEvent.click(screen.getByRole('tab', { name: /inspector\.tabs\.members/ })); - fireEvent.click(screen.getByTitle('inspector.members.manageTitle')); - - expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/manage?podId=pod-1'); - }); -}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2a12832749..b5e0a190f1 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -85,6 +85,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { const landing = read('../landing/v2-landing.css'); const podChat = read('../components/V2PodChat.tsx'); const podsSidebar = read('../components/V2PodsSidebar.tsx'); + const workspaceInspector = read('../components/V2Inspector.tsx'); + const v2Layout = read('../components/V2Layout.tsx'); const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); const v2App = read('../V2App.tsx'); @@ -344,6 +346,33 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toContain(':not(.v2-pods-aside):not(.v2-pane--inspector)'); }); + test('workspace inspector is the small three-card replacement, with a phone sheet rather than legacy tabs', () => { + // TASK-129 replaces the 86KB tabbed inspector in the same cutover as the + // artboard cards. Keeping the old component anywhere in the route would + // turn this back into a skin over two competing inspector models. + expect(fs.existsSync(path.join(__dirname, '../components/V2PodInspector.tsx'))).toBe(false); + expect(workspaceInspector).toContain('className="v2-workspace-inspector__card"'); + expect(workspaceInspector).toContain("'inspector.workspace.agentsIn'"); + expect(workspaceInspector).toContain("'inspector.workspace.needsYou'"); + expect(workspaceInspector).toContain("'inspector.workspace.boardToday'"); + expect(workspaceInspector).toContain('v2-workspace-inspector__state--${state.kind}'); + expect(workspaceInspector).not.toContain('v2-inspector__tabs'); + expect(workspaceInspector).not.toContain('v2-inspector__progress'); + + const card = ruleBody(v2, '.v2-workspace-inspector__card'); + expect(card).toContain('border: 1px solid var(--v2-border)'); + expect(card).toContain('border-radius: 6px'); + expect(ruleBody(v2, '.v2-workspace-inspector__state--needs-you')).toContain('background: var(--v2-accent)'); + expect(ruleBody(v2, '.v2-workspace-inspector__state--working')).toContain('animation: v2-pulse'); + + const phoneSheetStart = v2.lastIndexOf('/* The inspector is a bottom sheet on phones'); + const phoneSheet = v2.slice(phoneSheetStart, v2.indexOf('/* ---------- Workspace inspector', phoneSheetStart)); + expect(phoneSheetStart).toBeGreaterThan(-1); + expect(phoneSheet).toContain('top: auto'); + expect(phoneSheet).toContain('width: 100%'); + expect(v2Layout).toContain('className="v2-inspector-backdrop"'); + }); + test('v2 claims the page canvas — the V1 dark body cannot show through', () => { // App.tsx stamps `modern-ui` (V1 dark gradient) on unconditionally; // .v2-root paints only its own box. Without this override the V1 dark @@ -434,7 +463,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-thread-block')).toContain('margin-bottom'); }); - test('motion timing is tokenized and all three existing V2 animations consume it', () => { + test('motion timing is tokenized and all three active V2 animations consume it', () => { expect(tokens).toContain('--motion-stagger: 0.18s'); expect(tokens).toContain('--motion-breath: 1.2s'); expect(tokens).toContain('--motion-ease-breath: ease-in-out'); @@ -458,7 +487,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { } expect(ruleBody(v2, '.v2-chat__typing-dots > span')).toContain('var(--motion-breath)'); - expect(ruleBody(v2, '.v2-inspector__now-pulse')).toContain('var(--motion-pulse)'); + expect(ruleBody(v2, '.v2-workspace-inspector__state--working')).toContain('var(--motion-pulse)'); expect(ruleBody(v2, '.v2-spinner')).toContain('var(--motion-spin)'); }); @@ -650,11 +679,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-board__detail-updates li')).toContain('border-left: 3px solid var(--v2-border)'); }); - test('the inspector activity pulse uses semantic tokens, not raw color literals', () => { - const pulse = ruleBody(v2, '.v2-inspector__now-pulse'); - expect(pulse).toContain('var(--v2-success-ring-strong)'); - expect(v2.slice(v2.indexOf('@keyframes v2-now-pulse'), v2.indexOf('.v2-inspector__now-meta'))) - .toContain('var(--v2-success-ring)'); + test('the workspace inspector working dot is cobalt and uses the semantic pulse timing', () => { + const pulse = ruleBody(v2, '.v2-workspace-inspector__state--working'); + expect(pulse).toContain('background: var(--v2-accent)'); + expect(pulse).toContain('animation: v2-pulse var(--motion-pulse) var(--motion-ease-state) infinite'); }); test('v2 focus treatment reuses the shared focus-ring token', () => { @@ -899,12 +927,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { '.v2-root button.v2-chat__mode-option--active', '.v2-root button.v2-first-run__hello', '.v2-syscard__cta', - '.v2-inspector__action--primary', - '.v2-root a.v2-inspector__btn--primary', '.v2-root button.v2-approval__btn--approve', ]; - test('all eight filled controls use ink, never cobalt', () => { + test('all six ordinary filled controls use ink, never cobalt', () => { for (const selector of filledControls) { const rule = selectorRuleBody(v2, selector); expect(rule).toContain('var(--v2-ink)'); @@ -912,11 +938,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { } }); - test('chat and inspector surfaces do not paint with accent-soft', () => { + test('chat and workspace-inspector surfaces do not paint with accent-soft', () => { const prefixes = [ '.v2-chat', '.v2-msg', - '.v2-inspector', + '.v2-workspace-inspector', '.v2-approval', '.v2-syscard', '.v2-thread-card', @@ -931,11 +957,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(offenders).toEqual([]); }); - test('hover under chat and inspector changes fill only, never accent color, border, or shadow', () => { + test('hover under chat and workspace inspector changes fill only, never accent color, border, or shadow', () => { const prefixes = [ '.v2-chat', '.v2-msg', - '.v2-inspector', + '.v2-workspace-inspector', '.v2-approval', '.v2-syscard', '.v2-thread-card', @@ -960,7 +986,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { '.v2-thread-card--addressed .v2-thread-card__count', '.v2-msg__mention', '.v2-msg__content a', - '.v2-inspector__link', + '.v2-workspace-inspector__state--needs-you', '.v2-root button.v2-approval__result-link', '.v2-root button.v2-chat__new-pod-copy', '.v2-root .v2-chat__new-pod-error button', @@ -1091,10 +1117,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(lhStart).toBeGreaterThan(-1); const lhBlock = v2.slice(lhStart, v2.indexOf('}', lhStart)); expect(lhBlock).toContain('line-height: 1.6'); - for (const sel of ['.v2-msg__content', '.v2-chat__composer-hint', '.v2-inspector__pod-meta']) { + for (const sel of ['.v2-msg__content', '.v2-chat__composer-hint']) { expect(lhBlock).toContain(`.v2-root:lang(zh) ${sel}`); } - const floorStart = v2.indexOf('.v2-root:lang(zh) .v2-chat__composer-hint,\n.v2-root:lang(zh) button.v2-inspector__tab'); + expect(ruleBody(v2, '.v2-root:lang(zh) .v2-workspace-inspector__empty')).toContain('line-height: 1.6'); + const floorStart = v2.indexOf('.v2-root:lang(zh) .v2-workspace-inspector__actor,\n.v2-root:lang(zh) .v2-workspace-inspector__task-meta'); expect(floorStart).toBeGreaterThan(-1); expect(v2.slice(floorStart, v2.indexOf('}', floorStart))).toContain('font-size: 12px'); }); diff --git a/frontend/src/v2/components/V2Inspector.tsx b/frontend/src/v2/components/V2Inspector.tsx new file mode 100644 index 0000000000..850e3a5e30 --- /dev/null +++ b/frontend/src/v2/components/V2Inspector.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import CloseIcon from '@mui/icons-material/Close'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import V2Avatar from './V2Avatar'; +import { UseV2PodDetailResult, V2Agent } from '../hooks/useV2PodDetail'; +import { useV2Api } from '../hooks/useV2Api'; + +interface V2InspectorProps { + detail: UseV2PodDetailResult; + onClose?: () => void; + onOpenInvite?: () => void; +} + +interface AttentionItem { + id: string; + kind: 'mention' | 'approval' | 'decision'; + title: string; + detail?: string; + actorName?: string; + podId: string; + messageId?: string; + threadRootId?: string; +} + +interface TaskItem { + taskId: string; + title: string; + status: string; + assignee?: string; + updatedAt?: string; +} + +const isWorkingTask = (task: TaskItem): boolean => ( + task.status === 'claimed' || task.status === 'in_progress' +); + +const isDoneTask = (task: TaskItem): boolean => ( + task.status === 'done' || task.status === 'completed' +); + +const labelForAgent = (agent: V2Agent): string => ( + agent.profile?.displayName || agent.displayName || agent.agentName +); + +const matchesAgent = (agent: V2Agent, value?: string): boolean => { + const candidate = (value || '').toLowerCase(); + return candidate === (agent.agentName || '').toLowerCase() + || candidate === labelForAgent(agent).toLowerCase() + || candidate === (agent.instanceId || '').toLowerCase(); +}; + +const agentState = ( + agent: V2Agent, + attention: AttentionItem[], + tasks: TaskItem[], +): { kind: 'needs-you' | 'working' | 'idle'; title?: string } => { + const request = attention.find((item) => matchesAgent(agent, item.actorName)); + if (request) return { kind: 'needs-you', title: request.title }; + + const task = tasks.find((item) => isWorkingTask(item) && matchesAgent(agent, item.assignee)); + if (task) return { kind: 'working', title: task.title }; + + if (agent.status === 'working') return { kind: 'working' }; + return { kind: 'idle' }; +}; + +const V2Inspector: React.FC = ({ detail, onClose, onOpenInvite }) => { + const { pod, agents } = detail; + const api = useV2Api(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const [attention, setAttention] = useState([]); + const [tasks, setTasks] = useState([]); + + useEffect(() => { + if (!pod?._id) { + setAttention([]); + return undefined; + } + let active = true; + api.get<{ items?: AttentionItem[] }>('/api/activity/decision-queue') + .then((data) => { + if (!active) return; + const items = Array.isArray(data?.items) ? data.items : []; + setAttention(items.filter((item) => item.podId === pod._id)); + }) + .catch(() => { if (active) setAttention([]); }); + return () => { active = false; }; + }, [api, pod?._id]); + + useEffect(() => { + if (!pod?._id) { + setTasks([]); + return undefined; + } + let active = true; + api.get<{ tasks?: TaskItem[] }>(`/api/v1/tasks/${encodeURIComponent(pod._id)}`) + .then((data) => { if (active) setTasks(Array.isArray(data?.tasks) ? data.tasks : []); }) + .catch(() => { if (active) setTasks([]); }); + return () => { active = false; }; + }, [api, pod?._id]); + + const board = useMemo(() => { + const open = tasks.filter((task) => task.status === 'pending').length; + const inProgress = tasks.filter(isWorkingTask).length; + const done = tasks.filter(isDoneTask).length; + const rows = [...tasks] + .sort((left, right) => { + const rank = (task: TaskItem) => isWorkingTask(task) ? 0 : task.status === 'pending' ? 1 : isDoneTask(task) ? 2 : 3; + return rank(left) - rank(right) + || new Date(right.updatedAt || 0).getTime() - new Date(left.updatedAt || 0).getTime(); + }) + .slice(0, 3); + return { open, inProgress, done, rows }; + }, [tasks]); + + if (!pod) return null; + + const openAttention = (item: AttentionItem) => { + const target = item.threadRootId || item.messageId; + navigate(`/v2/pods/${item.podId}${target ? `#message-${target}` : ''}`); + }; + + const agentStatus = (state: ReturnType): string => { + if (state.kind === 'needs-you') return t('inspector.workspace.agentStatus.needsYou', { title: state.title }); + if (state.kind === 'working' && state.title) return t('inspector.workspace.agentStatus.workingTask', { title: state.title }); + if (state.kind === 'working') return t('inspector.workspace.agentStatus.working'); + return t('inspector.workspace.agentStatus.idle'); + }; + + return ( + + ); +}; + +export default V2Inspector; diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 96601fe86a..413dc6b053 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import V2NavRail from './V2NavRail'; import V2PodsSidebar from './V2PodsSidebar'; import V2PodChat from './V2PodChat'; -import V2PodInspector from './V2PodInspector'; +import V2Inspector from './V2Inspector'; import V2InviteModal, { type V2InviteTab } from './V2InviteModal'; import V2FirstRunHero from './V2FirstRunHero'; import { useV2Pods } from '../hooks/useV2Pods'; @@ -16,11 +16,6 @@ interface V2LayoutProps { selectionMode?: 'auto' | 'param'; } -export type InspectorView = - | { kind: 'overview' } - | { kind: 'member'; agentKey: string } - | { kind: 'artifact'; artifactId: string }; - const INSPECTOR_PREF_KEY = 'v2.inspectorCollapsed'; const LAST_POD_KEY = 'v2:lastPodId'; const INVITE_BLOCKED_POD_TYPES = new Set(['agent-room', 'agent-dm']); @@ -76,8 +71,7 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { const { pods, loading } = podsState; const [inspectorCollapsed, setInspectorCollapsed] = useState(readInspectorCollapsed()); - const [inspectorView, setInspectorView] = useState({ kind: 'overview' }); - // Invite modal lives here (not in V2PodInspector) so the chat header + // Invite modal lives here (not in V2Inspector) so the chat header // invite icon and the inspector "+ Invite" button can both open it. The // modal itself is V2InviteModal — this component just owns open/close. const [inviteOpen, setInviteOpen] = useState(false); @@ -102,53 +96,25 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { setInspectorCollapsed((prev) => { const next = !prev; writeInspectorCollapsed(next); - // Always reset to overview when re-opening, so the user lands in a - // predictable place rather than the last sub-page. - if (!next) setInspectorView({ kind: 'overview' }); return next; }); }, []); - const openInspectorMember = useCallback((agentKey: string) => { - if (!agentKey) return; - setInspectorView({ kind: 'member', agentKey }); + const openInspector = useCallback(() => { setInspectorCollapsed(false); writeInspectorCollapsed(false); }, []); - const openInspectorArtifact = useCallback((artifactId: string) => { - if (!artifactId) return; - setInspectorView({ kind: 'artifact', artifactId }); - setInspectorCollapsed(false); - writeInspectorCollapsed(false); - }, []); - // Open inspector by ObjectStore filename — used when a chat file pill is - // clicked. The inspector resolves the filename → artifactId via its own - // `podFiles` state and routes to the artifact sub-page. Goes through a - // pending-state pattern (vs. an imperative ref) so the resolution stays - // declarative and survives re-mounts. - const [pendingOpenFileName, setPendingOpenFileName] = useState(null); - const openInspectorByFileName = useCallback((fileName: string) => { + // File pills stay useful without an artifact sub-page in the compact + // inspector: mint the same authorized ObjectStore URL and open it directly. + const openFile = useCallback((fileName: string) => { if (!fileName) return; - // Mobile: the inspector pane is `display:none` below 760px (v2.css media - // query), so routing the click into the artifact view would render - // nothing. Bypass the inspector entirely and open the file via signed - // URL in a new tab — same behavior the inspector's "Open" button uses. - if (typeof window !== 'undefined' && window.matchMedia?.('(max-width: 760px)').matches) { - void getSignedAttachmentUrl(`/api/uploads/${fileName}`).then((signed) => { - if (signed) window.open(signed, '_blank', 'noopener,noreferrer'); - }); - return; - } - setPendingOpenFileName(fileName); - setInspectorCollapsed(false); - writeInspectorCollapsed(false); + void getSignedAttachmentUrl(`/api/uploads/${fileName}`).then((signed) => { + if (signed) window.open(signed, '_blank', 'noopener,noreferrer'); + }); }, []); - const clearPendingOpenFileName = useCallback(() => setPendingOpenFileName(null), []); - const resetInspectorView = useCallback(() => setInspectorView({ kind: 'overview' }), []); - // When pod changes, drop any stale sub-page state and close the mobile - // drawer (in case navigation came from somewhere other than a drawer tap). + // Close the mobile drawer when navigation came from somewhere other than a + // drawer tap. useEffect(() => { - setInspectorView({ kind: 'overview' }); setMobileNavOpen(false); if (paramPodId) writeLastPodId(paramPodId); }, [paramPodId]); @@ -225,24 +191,25 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { firstRunVisible={firstRunVisible} inspectorCollapsed={inspectorCollapsed} onToggleInspector={selectedPodId ? toggleInspector : undefined} - onOpenMember={openInspectorMember} + onOpenMember={openInspector} onOpenInvite={inviteEnabled ? openInvite : undefined} - onOpenFile={openInspectorByFileName} + onOpenFile={openFile} onOpenMobileNav={openMobileNav} /> {selectedPodId && !inspectorCollapsed && ( - openInvite() : undefined} - pendingOpenFileName={pendingOpenFileName} - onPendingOpenFileNameConsumed={clearPendingOpenFileName} - /> + <> +