diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 59842da9f5..acb8988702 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -60,7 +60,7 @@ "back": "Back to board", "intro": "What happened on this task, in order. Each entry carries the hash of the one before it, so a changed entry breaks the chain.", "loading": "Loading record…", - "access": "This record isn't available. The pod may not keep records, or you no longer have access to it.", + "access": "The pod may not keep records, or you no longer have access.", "members": "Only members of this pod can read its record.", "store": "The record store is unavailable right now. Try again shortly.", "error": "Could not load the record. Try again.", @@ -70,7 +70,6 @@ "counts_other": "{{count}} entries", "run": "Run {{id}}", "empty": "Nothing has been recorded for this task yet.", - "entry": "Entry {{number}}", "downloadJsonl": "Download JSONL", "downloadOtlp": "Download OTLP", "downloadError": "Could not download the record. Try again.", @@ -78,8 +77,7 @@ "unknown": "Unknown actor", "board": "Board", "system": "System: {{id}}", - "policy": "Policy: {{id}}", - "agent": "{{name}} (agent)" + "policy": "Policy: {{id}}" }, "kinds": { "task_claimed": "Claimed", @@ -91,6 +89,31 @@ "permission_decided": "Approval decided", "tool_call": "Tool call", "tool_update": "Tool update" + }, + "unavailableTitle": "This record isn't available", + "about": "About the record", + "download": "Download", + "details": "Details", + "entryKey": "entry", + "line": { + "leaseUntil": "lease until {{time}}", + "reason": "reason {{reason}}", + "doneBy": "done by {{name}}", + "askedOf": "asked {{name}}" + }, + "reasons": { + "blocked": "blocked", + "lease_expired": "lease expired", + "reassigned": "reassigned", + "abandoned": "abandoned" + }, + "decisions": { + "allow_once": "allow once", + "reject_once": "reject once", + "allow_always": "allow always", + "reject_always": "reject always", + "selected": "selected", + "cancelled": "cancelled" } }, "common": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 57fc27502a..07526afccd 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -59,7 +59,7 @@ "back": "返回看板", "intro": "按顺序列出此任务发生的事。每条记录都带有上一条的哈希,任何一条被改动,链就会断开。", "loading": "正在加载记录…", - "access": "此记录不可用。该 Pod 可能没有开启记录,或你已无权访问它。", + "access": "该 Pod 可能没有开启记录,或你已无权访问。", "members": "只有此 Pod 的成员可以查看它的记录。", "store": "记录存储暂时不可用,请稍后重试。", "error": "无法加载记录,请重试。", @@ -69,7 +69,6 @@ "counts_other": "{{count}}条记录", "run": "运行 {{id}}", "empty": "此任务还没有任何记录。", - "entry": "第{{number}}条", "downloadJsonl": "下载 JSONL", "downloadOtlp": "下载 OTLP", "downloadError": "无法下载记录,请重试。", @@ -77,8 +76,7 @@ "unknown": "未知操作者", "board": "看板", "system": "系统:{{id}}", - "policy": "策略:{{id}}", - "agent": "{{name}}(智能体)" + "policy": "策略:{{id}}" }, "kinds": { "task_claimed": "已认领", @@ -90,6 +88,31 @@ "permission_decided": "审批已决定", "tool_call": "工具调用", "tool_update": "工具更新" + }, + "unavailableTitle": "此记录不可用", + "about": "关于此记录", + "download": "下载", + "details": "详情", + "entryKey": "条目", + "line": { + "leaseUntil": "租约至{{time}}", + "reason": "原因:{{reason}}", + "doneBy": "由 {{name}} 完成", + "askedOf": "询问 {{name}}" + }, + "reasons": { + "blocked": "受阻", + "lease_expired": "租约到期", + "reassigned": "已改派", + "abandoned": "已放弃" + }, + "decisions": { + "allow_once": "允许一次", + "reject_once": "拒绝一次", + "allow_always": "始终允许", + "reject_always": "始终拒绝", + "selected": "已选择", + "cancelled": "已取消" } }, "common": { diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 529e4c8d7a..9726475616 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -359,7 +359,7 @@ const V2App: React.FC = () => { /> , false, false)} + element={feature(t('taskRecord.title'), , false, false, 'v2-feature--task-record')} /> Promise, otlp: () => Promise = () => Promise.resolve({ resourceSpans: [] })) => { +const tasksUrl = `/api/v1/tasks/${POD}`; +const wire = ( + record: () => Promise, + otlp: () => Promise = () => Promise.resolve({ resourceSpans: [] }), + tasks: () => Promise = () => Promise.reject(new Error('no tasks read in this test')), +) => { mockGet.mockImplementation((url: string) => { if (url === pageUrl) return record(); + if (url === tasksUrl) return tasks(); if (url === `${recordUrl}?format=otlp`) return otlp(); if (url === `/api/pods/${POD}`) return Promise.resolve({ members: [{ _id: HUMAN, username: 'ana', displayName: 'Ana' }, { _id: AGENT, username: 'scout' }] }); return Promise.reject(new Error(`unexpected ${url}`)); @@ -83,8 +90,8 @@ test('ids that name Object properties show as ids, never as an inherited value', line(2, 'task.done', { id: 'board', type: 'system' }, { doneBy: 'constructor' }), ].join('\n'))); setup(); - expect(await screen.findByText('__proto__ ·', { exact: false })).toBeInTheDocument(); - expect(screen.getByText('constructor')).toBeInTheDocument(); + expect(await screen.findByText('__proto__')).toBeInTheDocument(); + expect(screen.getByText('done by constructor')).toBeInTheDocument(); }); test('keeps reading past a full page, so a long chain is not shown short', async () => { @@ -98,9 +105,9 @@ test('keeps reading past a full page, so a long chain is not shown short', async return Promise.reject(new Error(`unexpected ${url}`)); }); setup(); - // Both pages render: the first and the last entry of the 1002 are on screen. - expect(await screen.findByText('Entry 1002')).toBeInTheDocument(); - expect(screen.getByText('Entry 1')).toBeInTheDocument(); + // Both pages render: all 1002 entries are rows on screen. + expect(await screen.findByText('TASK-7 · 1002 entries', { selector: 'p' })).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(1002); expect(mockGet).toHaveBeenCalledWith(second, expect.anything()); // A short second page ends the read: no third request. expect(mockGet.mock.calls.filter(([url]) => url.startsWith(recordUrl))).toHaveLength(2); @@ -116,7 +123,8 @@ test('a failure partway through the chain shows the error, never the partial rec }); setup(); expect(await screen.findByText(i18n.t('taskRecord.store'))).toBeInTheDocument(); - expect(screen.queryByText('Entry 1')).toBeNull(); + expect(screen.queryByText('Claimed')).toBeNull(); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); }); test('reads the JSONL as text and shows every entry with member names, unknown kinds by name', async () => { @@ -127,29 +135,33 @@ test('reads the JSONL as text and shows every entry with member names, unknown k // A one-line record is still text: the transform must not parse it as JSON. const config = mockGet.mock.calls.find(([url]) => url === pageUrl)[1]; expect(config.transformResponse[0](line(1, 'task.done', null))).toBe(line(1, 'task.done', null)); - expect(screen.getByText('4 entries')).toBeInTheDocument(); + expect(screen.getByText('TASK-7 · 4 entries', { selector: 'p' })).toBeInTheDocument(); expect(screen.getByText(`Run ${RUN}`)).toBeInTheDocument(); expect(screen.getByText('Released')).toBeInTheDocument(); - expect(screen.getByText('blocked')).toBeInTheDocument(); + expect(screen.getByText('reason blocked')).toBeInTheDocument(); expect(screen.getByText('Done')).toBeInTheDocument(); expect(screen.getByText('harness.switched')).toBeInTheDocument(); - await waitFor(() => expect(screen.getAllByText(/scout \(agent\)/)).toHaveLength(2)); - expect(screen.getByText(/^Board ·/)).toBeInTheDocument(); - expect(screen.getByText(/^Unknown actor ·/)).toBeInTheDocument(); - expect(screen.getByText('Ana')).toBeInTheDocument(); - expect(screen.getByText('Entry 4')).toBeInTheDocument(); + // An agent is its name and avatar, never "(agent)". + await waitFor(() => expect(screen.getAllByText('scout')).toHaveLength(2)); + expect(screen.queryByText(/\(agent\)/)).toBeNull(); + expect(screen.getByText('Board')).toBeInTheDocument(); + expect(screen.getByText('Unknown actor')).toBeInTheDocument(); + expect(screen.getByText('done by Ana')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'TASK-7' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Back to board' })).toHaveAttribute('href', `/v2/pods/${POD}/board`); + // The intro sits behind the (i) beside the title, not on the page. + expect(screen.getByRole('button', { name: 'About the record' })).toBeInTheDocument(); }); test('an empty chain says so rather than showing a blank page', async () => { wire(() => Promise.resolve('')); setup(); expect(await screen.findByText('Nothing has been recorded for this task yet.')).toBeInTheDocument(); - expect(screen.getByText('0 entries')).toBeInTheDocument(); + expect(screen.getByText('TASK-7 · 0 entries', { selector: 'p' })).toBeInTheDocument(); }); test.each([ - [404, "This record isn't available. The pod may not keep records, or you no longer have access to it.", false], + [404, "This record isn't availableThe pod may not keep records, or you no longer have access.", false], [403, 'Only members of this pod can read its record.', false], [503, 'The record store is unavailable right now. Try again shortly.', true], [500, 'Could not load the record. Try again.', true], @@ -178,7 +190,7 @@ test('a JSON line that is not a record entry makes the record unreadable too', a test('a task id the route would refuse is never requested', async () => { wire(() => Promise.resolve(JSONL)); setup('bad id'); - expect(await screen.findByRole('alert')).toHaveTextContent("This record isn't available."); + expect(await screen.findByRole('alert')).toHaveTextContent("This record isn't available"); expect(mockGet).not.toHaveBeenCalled(); }); @@ -194,16 +206,20 @@ test('downloads the served JSONL bytes and the OTLP export as files', async () = let failOtlp = true; wire(() => Promise.resolve(JSONL), () => (failOtlp ? Promise.reject(new Error('down')) : Promise.resolve({ resourceSpans: [{ id: 1 }] }))); setup(); - fireEvent.click(await screen.findByRole('button', { name: 'Download JSONL' })); + fireEvent.click(await screen.findByRole('button', { name: 'Download' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Download JSONL' })); + expect(screen.queryByRole('menu')).toBeNull(); expect(saved[0].name).toBe('TASK-7.record.jsonl'); expect(saved[0].type).toBe('application/x-ndjson'); await expect(saved[0].text).resolves.toBe(JSONL); - fireEvent.click(screen.getByRole('button', { name: 'Download OTLP' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Download OTLP' })); expect(await screen.findByText('Could not download the record. Try again.')).toBeInTheDocument(); expect(saved).toHaveLength(1); failOtlp = false; - fireEvent.click(screen.getByRole('button', { name: 'Download OTLP' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Download OTLP' })); await waitFor(() => expect(saved).toHaveLength(2)); expect(saved[1].name).toBe('TASK-7.record.otlp.json'); await expect(saved[1].text).resolves.toBe(JSON.stringify({ resourceSpans: [{ id: 1 }] })); @@ -216,7 +232,197 @@ test('Chinese copy covers the page', async () => { wire(() => Promise.resolve(JSONL)); setup(); expect(await screen.findByText('已认领')).toBeInTheDocument(); - expect(screen.getByText('4条记录')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '下载 JSONL' })).toBeInTheDocument(); + expect(screen.getByText('TASK-7 · 4条记录', { selector: 'p' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '下载' })); + expect(screen.getByRole('menuitem', { name: '下载 JSONL' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: '返回看板' })).toBeInTheDocument(); + expect(screen.getByText('原因:受阻')).toBeInTheDocument(); + expect(screen.getByText('由 Ana 完成')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '关于此记录' })).toBeInTheDocument(); +}); + +test('an entry\'s data folds under its row, entry number included', async () => { + wire(() => Promise.resolve(JSONL)); + setup(); + const row = (await screen.findByText('Released')).closest('button') as HTMLButtonElement; + expect(row).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('reason', { selector: 'dt' })).toBeNull(); + fireEvent.click(row); + expect(row).toHaveAttribute('aria-expanded', 'true'); + const more = document.getElementById(row.getAttribute('aria-controls') as string) as HTMLElement; + expect(more).toHaveTextContent('entry2reasonblocked'); + fireEvent.click(row); + expect(row).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('reason', { selector: 'dt' })).toBeNull(); +}); + +test('each row says its entry in one line: lease, ask, decision, tool call', async () => { + wire(() => Promise.resolve([ + line(1, 'task.claimed', { id: AGENT, type: 'agent' }, { leaseUntil: '2026-10-28T10:31:00.000Z' }), + line(2, 'permission.requested', { id: AGENT, type: 'agent' }, { question: 'Open pull request #409?', askedOf: { id: HUMAN, role: 'requester' } }), + line(3, 'permission.decided', { id: HUMAN, type: 'human' }, { outcome: 'selected', kind: 'allow_once', decidedBy: HUMAN }), + line(4, 'tool.call', { id: AGENT, type: 'agent' }, { toolCallId: 'tc-4', title: 'Open', name: 'gh.pr_create', status: 'completed' }), + ].join('\n'))); + setup(); + const lease = new Date('2026-10-28T10:31:00.000Z').toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + expect(await screen.findByText(`lease until ${lease}`)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Open pull request #409? · asked Ana')).toBeInTheDocument()); + expect(screen.getByText('allow once')).toBeInTheDocument(); + expect(screen.getByText('gh.pr_create · completed')).toBeInTheDocument(); +}); + +test('while the record loads, the header is in place and a status is announced', () => { + mockGet.mockImplementation(() => new Promise(() => undefined)); + setup(); + expect(screen.getByRole('status')).toHaveTextContent('Loading record…'); + expect(screen.getByRole('heading', { level: 1, name: 'Task record' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Download' })).toBeNull(); +}); + +test('the heading is the task\'s title from the board list, with the id on the line below', async () => { + wire(() => Promise.resolve(JSONL), undefined, () => Promise.resolve({ tasks: [ + { taskId: 'TASK-6', title: 'Not this one' }, + { taskId: 'TASK-7', title: 'CI check: fail a PR' }, + ] })); + setup(); + // Plain text: markup in a title is shown as typed, never parsed. + expect(await screen.findByRole('heading', { level: 2, name: 'CI check: fail a PR' })).toBeInTheDocument(); + expect(document.querySelector('.v2-task-record__task b')).toBeNull(); + expect(screen.getByText('TASK-7 · 4 entries', { selector: 'p' })).toBeInTheDocument(); + expect(mockGet).toHaveBeenCalledWith(tasksUrl); +}); + +test.each([ + ['the tasks read fails', () => Promise.reject(new Error('down'))], + ['the tasks read is refused', () => Promise.reject(Object.assign(new Error('http'), { response: { status: 403 } }))], + ['the task is missing from the list', () => Promise.resolve({ tasks: [{ taskId: 'TASK-8', title: 'Another' }] })], + ['the list is not a list', () => Promise.resolve({ tasks: 'nope' })], + ['the matching task\'s title is blank', () => Promise.resolve({ tasks: [{ taskId: 'TASK-7', title: ' ' }] })], + ['the matching task\'s title is not a string', () => Promise.resolve({ tasks: [{ taskId: 'TASK-7', title: { text: 'CI check' } }] })], +])('when %s, the heading stays the id and no error shows', async (_case, tasks) => { + wire(() => Promise.resolve(JSONL), undefined, tasks); + setup(); + expect(await screen.findByText('Claimed')).toBeInTheDocument(); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith(tasksUrl)); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + expect(screen.getByRole('heading', { level: 2, name: 'TASK-7' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('the record never waits on the tasks read: it renders with the id, then takes the title', async () => { + let land: (value: unknown) => void = () => undefined; + wire(() => Promise.resolve(JSONL), undefined, () => new Promise((resolve) => { land = resolve; })); + setup(); + expect(await screen.findByText('Claimed')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'TASK-7' })).toBeInTheDocument(); + land({ tasks: [{ taskId: 'TASK-7', title: 'CI check' }] }); + expect(await screen.findByRole('heading', { level: 2, name: 'CI check' })).toBeInTheDocument(); +}); + +test('release reasons and decisions read as words in both languages; unknown values show as written', async () => { + const lines = [ + line(1, 'task.released', { id: AGENT, type: 'agent' }, { reason: 'lease_expired' }), + line(2, 'permission.decided', { id: HUMAN, type: 'human' }, { outcome: 'selected', kind: 'reject_once', decidedBy: HUMAN }), + line(3, 'task.released', { id: AGENT, type: 'agent' }, { reason: 'moved_elsewhere' }), + line(4, 'permission.decided', { id: HUMAN, type: 'human' }, { outcome: 'cancelled', decidedBy: HUMAN }), + ].join('\n'); + wire(() => Promise.resolve(lines)); + const { unmount } = setup(); + expect(await screen.findByText('reason lease expired')).toBeInTheDocument(); + expect(screen.getByText('reject once')).toBeInTheDocument(); + expect(screen.getByText('reason moved elsewhere')).toBeInTheDocument(); + expect(screen.getByText('cancelled')).toBeInTheDocument(); + unmount(); + await i18n.changeLanguage('zh-CN'); + setup(); + expect(await screen.findByText('原因:租约到期')).toBeInTheDocument(); + expect(screen.getByText('拒绝一次')).toBeInTheDocument(); + expect(screen.getByText('已取消')).toBeInTheDocument(); + expect(screen.getByText('原因:moved elsewhere')).toBeInTheDocument(); +}); + +test('a digest shows as the frame draws it, with the full value in its title', async () => { + const full = `sha256:9f2c${'0'.repeat(56)}41ab`; + expect(shortDigest(full)).toBe('sha256:9f2c…41ab'); + expect(shortDigest('sha256:9f2c41ab')).toBe('sha256:9f2c41ab'); + expect(shortDigest('not a digest')).toBe('not a digest'); + wire(() => Promise.resolve(line(1, 'tool.call', { id: AGENT, type: 'agent' }, { toolCallId: 'tc-1', title: 'Open', argsDigest: full }))); + setup(); + fireEvent.click((await screen.findByText('Tool call')).closest('button') as HTMLButtonElement); + const cell = screen.getByText('sha256:9f2c…41ab'); + expect(cell).toHaveAttribute('title', full); +}); + +// Moves between two tasks' records inside one mounted page, as a link on the page would. +const setupTwoTasks = () => render( + next task + } /> +); +const recordOf = (taskId: string) => [ + line(1, 'task.claimed', { id: AGENT, type: 'agent' }).replace('"TASK-7"', `"${taskId}"`), + line(2, 'task.released', { id: AGENT, type: 'agent' }, { reason: 'blocked' }).replace('"TASK-7"', `"${taskId}"`), +].join('\n'); + +test.each([ + ['the old task\'s read lands late', true], + ['the next task\'s read fails', false], +])('after moving to another task, the old title never shows when %s', async (_case, late) => { + let landFirst: (value: unknown) => void = () => undefined; + let tasksCalls = 0; + mockGet.mockImplementation((url: string) => { + if (url.startsWith(`/api/pods/${POD}/record/tasks/`)) return Promise.resolve(recordOf(url.includes('TASK-8') ? 'TASK-8' : 'TASK-7')); + if (url === `/api/pods/${POD}`) return Promise.resolve({ members: [] }); + if (url === tasksUrl) { + tasksCalls += 1; + const list = { tasks: [{ taskId: 'TASK-7', title: 'Old title' }] }; + if (tasksCalls === 1) return late ? new Promise((resolve) => { landFirst = resolve; }) : Promise.resolve(list); + return Promise.reject(new Error('down')); + } + return Promise.reject(new Error(`unexpected ${url}`)); + }); + setupTwoTasks(); + if (!late) expect(await screen.findByRole('heading', { level: 2, name: 'Old title' })).toBeInTheDocument(); + else expect(await screen.findByRole('heading', { level: 2, name: 'TASK-7' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'next task' })); + expect(await screen.findByRole('heading', { level: 2, name: 'TASK-8' })).toBeInTheDocument(); + await waitFor(() => expect(tasksCalls).toBe(2)); + // Inside act, so a state update the late read makes is rendered before the check. + await act(async () => { + landFirst({ tasks: [{ taskId: 'TASK-7', title: 'Old title' }] }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + }); + expect(screen.queryByText('Old title')).toBeNull(); + expect(screen.getByRole('heading', { level: 2, name: 'TASK-8' })).toBeInTheDocument(); +}); + +test('an open row and the Download menu do not carry over to the next task', async () => { + mockGet.mockImplementation((url: string) => { + if (url.startsWith(`/api/pods/${POD}/record/tasks/`)) return Promise.resolve(recordOf(url.includes('TASK-8') ? 'TASK-8' : 'TASK-7')); + if (url === `/api/pods/${POD}`) return Promise.resolve({ members: [] }); + return Promise.reject(new Error('no tasks read')); + }); + setupTwoTasks(); + fireEvent.click((await screen.findByText('Released')).closest('button') as HTMLButtonElement); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Released').closest('button')).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(screen.getByRole('link', { name: 'next task' })); + expect(await screen.findByText('TASK-8 · 2 entries', { selector: 'p' })).toBeInTheDocument(); + expect(screen.queryByRole('menu')).toBeNull(); + expect(screen.getByText('Released').closest('button')).toHaveAttribute('aria-expanded', 'false'); +}); + +test('an entry of another kind shows its first two plain values, skipping objects between them', async () => { + wire(() => Promise.resolve(line(1, 'attempt.settled', { id: AGENT, type: 'agent' }, { attempt: 2, failure: { kind: 'exit', detail: 'x' }, outcome: 'failed', extra: 'not shown' }))); + setup(); + expect(await screen.findByText('2 · failed')).toBeInTheDocument(); + expect(screen.queryByText(/not shown/)).toBeNull(); +}); + +test('a long actor name keeps its full text in the title, so a truncated row still says who', async () => { + const long = 'a-very-long-actor-id-that-would-push-the-time-off-the-row-0123456789'; + wire(() => Promise.resolve(line(1, 'task.claimed', { id: long, type: 'human' }))); + setup(); + const name = await screen.findByText(long, { selector: '.v2-task-record__name' }); + expect(name.closest('.v2-task-record__who')).toHaveAttribute('title', long); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2d680a658d..c19dcdb3fc 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5756,6 +5756,71 @@ test('standalone teammate invite button keeps desktop and phone touch targets', expect(css).toMatch(/@media \(max-width: 760px\) \{\s*\.v2-chat__new-pod-action--invite > \.v2-chat__new-pod-copy \{ min-height: 44px;/); }); +// d215 s-task-record: the task record has its own sheet and v2-task-record__* names. +describe('task record layout', () => { + const css = read('../components/V2TaskRecord.css'); + const page = read('../components/V2TaskRecord.tsx'); + const bare = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const phone = css.slice(css.indexOf('@media (max-width: 760px)')); + const scope = '.v2-feature--task-record'; + test('the page imports its own sheet and renders none of the run trace\'s classes', () => { + expect(page).toContain("import './V2TaskRecord.css'"); + expect(page).not.toContain('V2RunTrace.css'); + expect(page).not.toMatch(/v2-run-trace/); + expect(read('../V2App.tsx')).toMatch(/, false, false, 'v2-feature--task-record'\)/); + }); + test('every v2-task-record class the page renders has a rule in its sheet', () => { + const used = [...new Set([...page.matchAll(/v2-task-record__[a-z-]+/g)].map((m) => m[0]))]; + expect(used.length).toBeGreaterThan(10); + used.forEach((name) => expect(css).toContain(`.${name}`)); + }); + test('the head runs the pane edge to edge: no legacy max width, no body padding at 1440 or 390', () => { + expect(ruleBody(css, `${scope} .v2-feature__legacy`)).toContain('max-width: none'); + expect(ruleBody(css, `${scope} > .v2-feature__body`)).toContain('padding: 0 0 28px'); + expect(ruleBody(css, `${scope} .v2-task-record__head`)).toContain('border-bottom: 1px solid var(--v2-border-soft)'); + expect(phone).toMatch(/\.v2-feature--task-record > \.v2-feature__body \{ padding: 0; \}/); + }); + test('a row is a 44px target at 1440 and 56px at 390, and its data line never pushes the page sideways', () => { + expect(ruleBody(css, `${scope} .v2-task-record__row`)).toContain('min-height: 44px'); + expect(phone).toMatch(/\.v2-task-record__row \{[^}]*min-height: 56px/); + expect(ruleBody(css, `${scope} .v2-task-record__main`)).toContain('min-width: 0'); + expect(ruleBody(css, `${scope} .v2-task-record__d`)).toContain('text-overflow: ellipsis'); + expect(ruleBody(css, `${scope} .v2-task-record__more dd`)).toContain('overflow-wrap: anywhere'); + expect(ruleBody(css, `${scope} .v2-task-record__foot code`)).toContain('overflow-wrap: anywhere'); + }); + test('the 390 head keys are 44px and the Download key is the icon alone', () => { + expect(phone).toMatch(/\.v2-task-record__download-key \{ width: 44px; height: 44px;/); + expect(phone).toMatch(/\.v2-task-record__download-word,[^{]*\{ display: none; \}/); + expect(phone).toMatch(/\.v2-task-record__body \{ padding: 16px 16px 12px; \}/); + expect(phone).toMatch(/\.v2-task-record__menu button \{ height: 44px; \}/); + }); + test('at 390 the (i) trigger itself is a 44px target, not only its wrapper (#3362 review)', () => { + // v2.css draws button.v2-hint__trigger 28px (1px border) at <=760px. The ::after sits on the + // padding edge, so -9px a side gives 26 + 18 = 44px, as measured in the browser. + expect(phone).toMatch(/\.v2-root \.v2-feature--task-record \.v2-task-record__head button\.v2-hint__trigger \{ position: relative; \}/); + expect(phone).toMatch(/\.v2-root \.v2-feature--task-record \.v2-task-record__head button\.v2-hint__trigger::after \{ content: ''; position: absolute; inset: -9px; \}/); + }); + test('a long actor name shrinks and truncates instead of pushing the time off the row (#3362 CodeRabbit)', () => { + const who = ruleBody(css, `${scope} .v2-task-record__who`); + expect(who).toContain('min-width: 0'); + expect(who).toContain('flex: 0 1 auto'); + const name = ruleBody(css, `${scope} .v2-task-record__name`); + expect(name).toContain('min-width: 0'); + expect(name).toContain('overflow: hidden'); + expect(name).toContain('text-overflow: ellipsis'); + }); + test('at 390 Try again in the unavailable block is 44px tall (#3362 review)', () => { + expect(phone).toMatch(/\.v2-feature--task-record \.v2-task-record__none \.v2-task-record__key \{ height: 44px; \}/); + }); + test('Done is the only filled mark, in cobalt soft; no rail or side border anywhere (D225)', () => { + expect(ruleBody(css, `${scope} .v2-task-record__mk--done`)).toContain('background: var(--v2-accent-soft)'); + const filled = [...bare.matchAll(/([^{}@]+)\{([^{}]*)\}/g)].filter(([, , body]) => /var\(--v2-accent(-soft)?\)/.test(body)).map(([, sel]) => sel.trim()); + expect(filled.filter((sel) => /__mk/.test(sel))).toEqual([`${scope} .v2-task-record__mk--done`]); + expect(bare).not.toMatch(/border-(left|inline-start)/); + expect(bare).not.toMatch(/inset\s+-?[1-9]\d*px\s+0\s+0/); + }); +}); + describe('run trace layout', () => { const css = read('../components/V2RunTrace.css'); test('long attribution IDs wrap, phone records stack, and links meet touch height', () => { @@ -5773,19 +5838,8 @@ describe('run trace layout', () => { const phone = css.slice(css.indexOf('@media (max-width: 760px)')); expect(phone).toMatch(/[,{]\s*\.v2-run-trace-link a[,\s][^{]*\{[^}]*min-height: 44px/); }); - test('every run-trace class the task record page renders keeps a rule in the sheet it imports', () => { - // V2TaskRecord imports V2RunTrace.css; a class it still renders must not lose its rule when the run - // trace page stops using it (#3322 review: __retention dropped, the record intro went full size). - const record = read('../components/V2TaskRecord.tsx'); - expect(record).toContain("import './V2RunTrace.css'"); - const used = [...new Set([...record.matchAll(/v2-run-trace__[a-z-]+/g)].map((m) => m[0]))]; - expect(used.length).toBeGreaterThan(0); - used.forEach((name) => expect(css).toContain(`.${name}`)); - expect(ruleBody(css, '.v2-run-trace__retention')).toContain('color: var(--v2-text-muted)'); - }); - test('task record downloads wrap at 390 rather than push the page sideways (TASK-562)', () => { - expect(ruleBody(css, '.v2-task-record__downloads')).toContain('flex-wrap: wrap'); - expect(ruleBody(css, '.v2-task-record__entries')).toContain('list-style: none'); + test('the run trace sheet carries no task record rule, so neither page moves the other (d215 s-task-record)', () => { + expect(css).not.toMatch(/v2-task-record/); }); }); diff --git a/frontend/src/v2/components/V2RunTrace.css b/frontend/src/v2/components/V2RunTrace.css index 8688177f6d..9c203a797d 100644 --- a/frontend/src/v2/components/V2RunTrace.css +++ b/frontend/src/v2/components/V2RunTrace.css @@ -5,8 +5,6 @@ /* Under an agent reply: as quiet as a timestamp (d215 s-chat, trace link). */ .v2-run-trace-link a { color: var(--v2-text-secondary); font-size: 12.5px; text-decoration: none; min-height: 32px; display: inline-flex; align-items: center; } .v2-run-trace-link a:hover { color: var(--v2-text-primary); text-decoration: underline; } -/* The task record page's intro line (V2TaskRecord imports this sheet). */ -.v2-run-trace__retention { color: var(--v2-text-muted); font-size: 12px; } .v2-run-trace__summary, .v2-run-trace__call { display: grid; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-lg); padding: 16px; margin: 12px 0; min-width: 0; } .v2-run-trace__meta, .v2-run-trace code, .v2-run-trace dd { font-family: var(--v2-font-mono); font-size: 12px; } .v2-run-trace code, .v2-run-trace dd, .v2-run-trace__call strong { overflow-wrap: anywhere; } @@ -20,8 +18,6 @@ .v2-run-trace button { min-height: 32px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } .v2-run-trace button:focus-visible, .v2-run-trace a:focus-visible, .v2-run-trace-link a:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; } .v2-run-trace-link { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } -.v2-task-record__entries { list-style: none; margin: 24px 0; padding: 0; } -.v2-task-record__downloads { display: flex; gap: 8px; flex-wrap: wrap; } @media (max-width: 760px) { .v2-run-trace__back, .v2-run-trace-link a, .v2-run-trace button { min-height: 44px; } .v2-run-trace dl { grid-template-columns: minmax(0, 1fr); gap: 4px; } diff --git a/frontend/src/v2/components/V2TaskRecord.css b/frontend/src/v2/components/V2TaskRecord.css new file mode 100644 index 0000000000..7d664d272e --- /dev/null +++ b/frontend/src/v2/components/V2TaskRecord.css @@ -0,0 +1,131 @@ +/* Task record (/v2/pods/:podId/tasks/:taskId/record), lifted from the d215 + frame: docs/design/artboards/d215/index.html, section s-task-record, frames + "task-record" (default and unavailable) at 1440 and 390. The page has its + own v2-task-record__* names, so a change to the run trace can't move it. + Values are the frame's; only the tokens are translated to --v2-*. + + Token map (as V2PodBoard.css): --sf surface, --p bg-subtle, --t surface-tint, + --d border-soft, --b border, --mu text-muted, --se text-secondary, --ink + text-primary, --ink2 message-text, --c accent, --c-text accent-text, --c-soft + accent-soft, --key-a/-b/-edge key-a/-b/-edge, --sh-key key-inset, --sh-pop + shadow-pop. Scoped to .v2-feature--task-record, the route's wrapper. */ + +.v2-feature--task-record > .v2-feature__body { padding: 0 0 28px; } +.v2-feature--task-record .v2-feature__legacy { max-width: none; } +.v2-feature--task-record .v2-task-record { min-width: 0; color: var(--v2-text-primary); } + +/* keys (.b, .b-g, .b-s, .b-sm, .ib) */ +.v2-feature--task-record .v2-task-record__key { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 34px; padding: 0 13px; border-radius: 10px; font: 600 13px/1 var(--v2-font); cursor: pointer; white-space: nowrap; text-decoration: none; transition: transform var(--v2-dur-fast) var(--v2-ease); } +.v2-feature--task-record .v2-task-record__key svg { width: 16px; height: 16px; flex: none; } +.v2-feature--task-record .v2-task-record__key:active { transform: translateY(1px); } +.v2-feature--task-record .v2-task-record__key:focus-visible, +.v2-feature--task-record .v2-task-record__row:focus-visible, +.v2-feature--task-record .v2-task-record__menu button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--v2-accent-soft), 0 0 0 1px var(--v2-accent); } +.v2-feature--task-record .v2-task-record__key--soft { color: var(--v2-text-primary); background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); border: 1px solid var(--v2-key-edge); box-shadow: var(--v2-key-inset); } +.v2-feature--task-record .v2-task-record__key--soft:hover { background: var(--v2-key-b); } +.v2-feature--task-record .v2-task-record__key--ghost { color: var(--v2-text-secondary); background: transparent; border: 1px solid transparent; } +.v2-feature--task-record .v2-task-record__key--ghost:hover { background: var(--v2-surface-tint); color: var(--v2-text-primary); } +.v2-feature--task-record .v2-task-record__key--sm { height: 28px; padding: 0 10px; border-radius: 8px; font-size: 12.5px; } +.v2-feature--task-record .v2-task-record__key--icon { width: 32px; height: 32px; padding: 0; border-radius: 8px; } +.v2-feature--task-record .v2-task-record__key--icon svg { width: 17px; height: 17px; } + +/* the pane header (.ph): back key, h1, (i), meta, Download */ +.v2-feature--task-record .v2-task-record__head { position: relative; display: flex; align-items: center; gap: 10px; height: 64px; padding: 0 20px; border-bottom: 1px solid var(--v2-border-soft); } +.v2-feature--task-record .v2-task-record__title { margin: 0; font: 800 20px/1 var(--v2-font-display); letter-spacing: -0.03em; white-space: nowrap; } +.v2-feature--task-record .v2-task-record__meta { min-width: 0; font: 500 12px/1 var(--v2-font-mono); color: var(--v2-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.v2-feature--task-record .v2-task-record__sp { flex: 1; } +.v2-feature--task-record .v2-task-record__download { position: relative; } +.v2-feature--task-record .v2-task-record__download-chev { display: inline-flex; } +.v2-feature--task-record .v2-task-record__menu { position: absolute; z-index: 6; top: calc(100% + 8px); right: 0; width: 220px; padding: 4px; border-radius: 10px; background: var(--v2-surface); box-shadow: var(--v2-shadow-pop); display: grid; gap: 2px; } +.v2-feature--task-record .v2-task-record__menu button { all: unset; box-sizing: border-box; cursor: pointer; height: 34px; padding: 0 10px; border-radius: 7px; display: flex; align-items: center; gap: 9px; font: 400 13px/1 var(--v2-font); color: var(--v2-text-primary); } +.v2-feature--task-record .v2-task-record__menu button:hover { background: var(--v2-surface-tint); } +.v2-feature--task-record .v2-task-record__menu button svg { width: 15px; height: 15px; color: var(--v2-text-secondary); } +.v2-feature--task-record .v2-task-record__ext { margin-left: auto; font: 500 11px/1 var(--v2-font-mono); color: var(--v2-text-muted); } + +/* the body (.pbody) */ +.v2-feature--task-record .v2-task-record__body { max-width: 936px; min-width: 0; padding: 24px 28px; } +.v2-feature--task-record .v2-task-record__task { margin: 0 0 20px; font: 700 22px/1.25 var(--v2-font-display); letter-spacing: -0.02em; text-wrap: balance; overflow-wrap: anywhere; } +.v2-feature--task-record .v2-task-record__id { display: none; } +.v2-feature--task-record .v2-task-record__alert { margin: 0 0 12px; padding: 10px 12px; border-radius: var(--v2-radius-lg); background: var(--v2-danger-soft); color: var(--v2-danger-text); font-size: 13px; } +.v2-feature--task-record .v2-task-record__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + +/* the timeline: one row per entry */ +.v2-feature--task-record .v2-task-record__day { margin: 0 0 4px 40px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-muted); } +.v2-feature--task-record .v2-task-record__list { list-style: none; margin: 0; padding: 0; } +.v2-feature--task-record .v2-task-record__list + .v2-task-record__day { margin-top: 16px; } +.v2-feature--task-record .v2-task-record__e { position: relative; display: grid; grid-template-columns: 28px minmax(0, 1fr); column-gap: 12px; } +.v2-feature--task-record .v2-task-record__e::before { content: ""; position: absolute; left: 13.5px; top: 0; bottom: 0; width: 1px; background: var(--v2-border-soft); } +.v2-feature--task-record .v2-task-record__e:first-child::before { top: 22px; } +.v2-feature--task-record .v2-task-record__e:last-child::before { bottom: auto; height: 22px; } +.v2-feature--task-record .v2-task-record__e:only-child::before { display: none; } +.v2-feature--task-record .v2-task-record__row { all: unset; box-sizing: border-box; grid-column: 1 / -1; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto 28px; column-gap: 12px; align-items: center; min-height: 44px; border-radius: 8px; cursor: pointer; min-width: 0; } +.v2-feature--task-record .v2-task-record__mk { position: relative; z-index: 1; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: var(--v2-surface); box-shadow: inset 0 0 0 1px var(--v2-border); color: var(--v2-text-secondary); } +.v2-feature--task-record .v2-task-record__mk svg { width: 14px; height: 14px; } +.v2-feature--task-record .v2-task-record__mk--done { background: var(--v2-accent-soft); box-shadow: inset 0 0 0 1px var(--v2-accent); color: var(--v2-accent-text); } +.v2-feature--task-record .v2-task-record__main { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13.5px; } +.v2-feature--task-record .v2-task-record__main b { font-weight: 600; white-space: nowrap; } +.v2-feature--task-record .v2-task-record__who { display: inline-flex; align-items: center; gap: 6px; flex: 0 1 auto; min-width: 0; max-width: 100%; color: var(--v2-message-text); white-space: nowrap; } +/* A long member name or actor id truncates; the full name is the who's title. */ +.v2-feature--task-record .v2-task-record__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-feature--task-record .v2-task-record__who .v2-task-record__av { width: 20px; height: 20px; flex: none; } +.v2-feature--task-record .v2-task-record__sys { width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center; background: var(--v2-surface-tint); color: var(--v2-text-secondary); flex: none; } +.v2-feature--task-record .v2-task-record__sys svg { width: 12px; height: 12px; } +.v2-feature--task-record .v2-task-record__d { min-width: 0; font: 500 12px/1.2 var(--v2-font-mono); color: var(--v2-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.v2-feature--task-record .v2-task-record__t { font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-muted); white-space: nowrap; } +.v2-feature--task-record .v2-task-record__chev { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; color: var(--v2-text-secondary); } +.v2-feature--task-record .v2-task-record__chev svg { width: 17px; height: 17px; transition: transform 160ms var(--v2-ease); } +.v2-feature--task-record .v2-task-record__row:hover .v2-task-record__chev { background: var(--v2-surface-tint); color: var(--v2-text-primary); } +.v2-feature--task-record .v2-task-record__e--open .v2-task-record__chev svg { transform: rotate(180deg); } +.v2-feature--task-record .v2-task-record__more { grid-column: 2 / -1; display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px 10px; align-items: center; margin: 2px 0 10px; padding: 12px 14px; border-radius: 10px; background: var(--v2-bg-subtle); box-shadow: inset 0 0 0 1px var(--v2-border-soft); } +.v2-feature--task-record .v2-task-record__more dt { font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-muted); overflow-wrap: anywhere; } +.v2-feature--task-record .v2-task-record__more dd { margin: 0; min-width: 0; font: 500 12.5px/1.4 var(--v2-font-mono); color: var(--v2-text-primary); overflow-wrap: anywhere; } +.v2-feature--task-record .v2-task-record__bone { height: 12px; width: 46%; border-radius: 4px; background: var(--v2-surface-tint); } +.v2-feature--task-record .v2-task-record__list--loading .v2-task-record__row { cursor: default; grid-template-columns: 28px minmax(0, 1fr); } +.v2-feature--task-record .v2-task-record__list--loading .v2-task-record__mk { background: var(--v2-surface-tint); box-shadow: none; } +.v2-feature--task-record .v2-task-record__foot { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--v2-border-soft); } +.v2-feature--task-record .v2-task-record__foot code { font: 500 11.5px/16px var(--v2-font-mono); color: var(--v2-text-muted); overflow-wrap: anywhere; } + +/* not available, and the states drawn in its block */ +.v2-feature--task-record .v2-task-record__none { margin: 140px auto 0; max-width: 380px; display: grid; justify-items: center; gap: 10px; text-align: center; } +.v2-feature--task-record .v2-task-record__tile { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--v2-surface-tint); color: var(--v2-text-secondary); box-shadow: inset 0 0 0 1px var(--v2-border-soft); } +.v2-feature--task-record .v2-task-record__none h2 { margin: 4px 0 0; font: 700 18px/1.25 var(--v2-font-display); letter-spacing: -0.01em; } +.v2-feature--task-record .v2-task-record__none p { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--v2-text-secondary); } +.v2-feature--task-record .v2-task-record__none .v2-task-record__key { margin-top: 4px; } + +/* ── 390 (.app.m, .mtop, .mbody2--rec) ─────────────────────────────────── */ +@media (max-width: 760px) { + .v2-feature--task-record > .v2-feature__body { padding: 0; } + .v2-feature--task-record .v2-task-record__head { position: sticky; top: 0; z-index: 5; height: 52px; gap: 8px; padding: 0 10px 0 12px; background: var(--v2-surface); } + .v2-feature--task-record .v2-task-record__title { flex: 1; min-width: 0; font: 700 16px/1.1 var(--v2-font-display); letter-spacing: -0.02em; overflow: hidden; text-overflow: ellipsis; } + .v2-feature--task-record .v2-task-record__meta, + .v2-feature--task-record .v2-task-record__sp, + .v2-feature--task-record .v2-task-record__download-word, + .v2-feature--task-record .v2-task-record__download-chev { display: none; } + /* 44px targets: the back key, the (i) and the Download icon key */ + .v2-feature--task-record .v2-task-record__head .v2-task-record__key--icon, + .v2-feature--task-record .v2-task-record__download-key { width: 44px; height: 44px; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--v2-text-secondary); } + .v2-feature--task-record .v2-task-record__head .v2-hint { width: 44px; height: 44px; justify-content: center; } + .v2-feature--task-record .v2-task-record__head .v2-hint--open { display: inline-flex; } + /* v2.css draws the shared trigger 28px at this width; the hit area grows to 44px. The + ::after is placed from the padding edge, inside the 1px border, so -9px makes 26 + 18 = 44 + (V2AgentBYO.css's -8px gives 42px when measured in the browser). */ + .v2-root .v2-feature--task-record .v2-task-record__head button.v2-hint__trigger { position: relative; } + .v2-root .v2-feature--task-record .v2-task-record__head button.v2-hint__trigger::after { content: ''; position: absolute; inset: -9px; } + .v2-feature--task-record .v2-task-record__head .v2-hint--open .v2-hint__content { position: absolute; top: 52px; left: auto; right: -40px; width: min(320px, calc(100vw - 32px)); padding: 10px 12px; border: 1px solid var(--v2-border); background: var(--v2-surface); box-shadow: var(--v2-shadow-pop); } + .v2-feature--task-record .v2-task-record__menu { top: calc(100% + 4px); } + .v2-feature--task-record .v2-task-record__menu button { height: 44px; } + .v2-feature--task-record .v2-task-record__body { padding: 16px 16px 12px; } + .v2-feature--task-record .v2-task-record__task { font-size: 19px; margin-bottom: 4px; } + .v2-feature--task-record .v2-task-record__id { display: block; margin: 0 0 16px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-muted); } + .v2-feature--task-record .v2-task-record__row { grid-template-columns: 28px minmax(0, 1fr) auto; min-height: 56px; align-items: start; padding-top: 8px; } + .v2-feature--task-record .v2-task-record__e::before { top: 0; } + .v2-feature--task-record .v2-task-record__e:first-child::before { top: 22px; } + .v2-feature--task-record .v2-task-record__e:last-child::before { height: 22px; } + .v2-feature--task-record .v2-task-record__chev { display: none; } + .v2-feature--task-record .v2-task-record__main { flex-wrap: wrap; row-gap: 3px; } + .v2-feature--task-record .v2-task-record__d { flex-basis: 100%; } + .v2-feature--task-record .v2-task-record__t { padding-top: 4px; } + .v2-feature--task-record .v2-task-record__more { grid-column: 1 / -1; margin-left: 40px; grid-template-columns: 84px minmax(0, 1fr); } + .v2-feature--task-record .v2-task-record__none { margin-top: 120px; padding: 0 16px; } + .v2-feature--task-record .v2-task-record__none .v2-task-record__key { height: 44px; } +} diff --git a/frontend/src/v2/components/V2TaskRecord.tsx b/frontend/src/v2/components/V2TaskRecord.tsx index c7f76e8697..bda0475610 100644 --- a/frontend/src/v2/components/V2TaskRecord.tsx +++ b/frontend/src/v2/components/V2TaskRecord.tsx @@ -1,8 +1,12 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { useV2Api } from '../hooks/useV2Api'; -import './V2RunTrace.css'; +import { appDateLocale } from '../utils/appLocale'; +import { KindGlyph, RecordGlyph, RecordGlyphName, ShellGlyph } from '../icons/glyphs'; +import V2Avatar from './V2Avatar'; +import V2Hint from './V2Hint'; +import './V2TaskRecord.css'; // Ids as the record route accepts them (backend/routes/podRecords.ts). const POD_ID = /^[a-f\d]{24}$/i; @@ -29,6 +33,38 @@ const KNOWN_KINDS = new Set([ // Data fields that hold a user id, shown by member name where one is known. const PERSON_FIELDS = new Set(['doneBy', 'decidedBy', 'issuedBy']); +// A pod member as the record shows them: a name, and an avatar when the pod read has one. +interface Member { name: string; src?: string; isBot?: boolean } + +// One mark per kind (d215 s-task-record). Done is the only filled one. +const KIND_MARKS: Record = { + 'task.claimed': 'play', 'task.released': 'block', 'task.done': 'check', + 'grant.issued': 'key', 'grant.revoked': 'key', + 'permission.requested': 'approval', 'permission.decided': 'check', + 'tool.call': 'term', 'tool.update': 'term', +}; +const KindMark: React.FC<{ kind: string }> = ({ kind }) => { + const mark = KIND_MARKS[kind] || 'dot'; + if (mark === 'approval') return ; + if (mark === 'check') return ; + return ; +}; + +// Values the writers put in a release's `reason` and a decision's `kind` / +// `outcome` (taskRecordService, kernelWorkSweepService, brokerRecordLines, +// record-v1 §2). Any other value shows as written, underscores as spaces. +const RELEASE_REASONS = new Set(['blocked', 'lease_expired', 'reassigned', 'abandoned']); +const DECISIONS = new Set(['allow_once', 'reject_once', 'allow_always', 'reject_always', 'selected', 'cancelled']); +const spaced = (value: string) => value.replace(/_/g, ' '); + +// A digest as the frame draws it: the algorithm, the first and last four hex +// characters. The full value stays in the cell's title. +const DIGEST = /^(sha256:)([0-9a-f]{4})[0-9a-f]{4,}([0-9a-f]{4})$/i; +export const shortDigest = (value: string): string => { + const match = DIGEST.exec(value); + return match ? `${match[1]}${match[2]}…${match[3]}` : value; +}; + // The server's MAX_PAGE_LINES. A page this long may have more after it. const RECORD_PAGE_LINES = 1000; @@ -79,7 +115,7 @@ const V2TaskRecord: React.FC = () => { const [lines, setLines] = useState([]); const [error, setError] = useState(null); const [downloadError, setDownloadError] = useState(false); - const [names, setNames] = useState>(() => new Map()); + const [names, setNames] = useState>(() => new Map()); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((value) => value + 1), []); @@ -125,34 +161,133 @@ const V2TaskRecord: React.FC = () => { useEffect(() => { let live = true; if (!valid) return undefined; - api.get<{ members?: Array<{ _id?: string; username?: string; displayName?: string }> }>(`/api/pods/${encodeURIComponent(podId)}`) + api.get<{ members?: Array<{ _id?: string; username?: string; displayName?: string; profilePicture?: string; isBot?: boolean }> }>(`/api/pods/${encodeURIComponent(podId)}`) .then((pod) => { if (!live) return; // A Map, not an object: an id like `__proto__` or `constructor` must // never resolve to an inherited property and reach the render. setNames(new Map((pod.members || []) .filter((member) => member._id) - .map((member) => [String(member._id), member.displayName || member.username || String(member._id)]))); + .map((member) => [String(member._id), { + name: member.displayName || member.username || String(member._id), + src: typeof member.profilePicture === 'string' ? member.profilePicture : undefined, + isBot: typeof member.isBot === 'boolean' ? member.isBot : undefined, + }]))); }) .catch(() => undefined); return () => { live = false; }; }, [api, podId, valid]); - const personLabel = useCallback((id: string) => names.get(id) || id, [names]); + // The task's title, from the board's own list (there is no single-task read). + // Never blocks the record: until it lands, or if it fails or the task is not + // in the list, the heading stays the id. + const [title, setTitle] = useState(null); + useEffect(() => { + let live = true; + setTitle(null); + if (!valid) return undefined; + api.get<{ tasks?: Array<{ taskId?: unknown; title?: unknown }> }>(`/api/v1/tasks/${encodeURIComponent(podId)}`) + .then((data) => { + if (!live || !Array.isArray(data?.tasks)) return; + const task = data.tasks.find((row) => row && row.taskId === taskId); + if (task && typeof task.title === 'string' && task.title.trim()) setTitle(task.title); + }) + .catch(() => undefined); + return () => { live = false; }; + }, [api, podId, taskId, valid]); + + const personLabel = useCallback((id: string) => names.get(id)?.name || id, [names]); const actorLabel = (actor: RecordLine['actor']) => { if (!actor) return t('taskRecord.actor.unknown'); if (actor.type === 'system') return actor.id === 'board' ? t('taskRecord.actor.board') : t('taskRecord.actor.system', { id: actor.id }); if (actor.type === 'policy') return t('taskRecord.actor.policy', { id: actor.id }); - if (actor.type === 'agent') return t('taskRecord.actor.agent', { name: personLabel(actor.id) }); return personLabel(actor.id); }; + // People and agents carry their avatar; Board, System, Policy and an unknown actor a tile. + const actorMark = (actor: RecordLine['actor']) => { + if (actor && (actor.type === 'agent' || actor.type === 'human')) { + const member = names.get(actor.id); + const kind = member?.isBot !== undefined ? (member.isBot ? 'agent' : 'human') : actor.type; + return ; + } + return ; + }; const kindLabel = (kind: string) => (KNOWN_KINDS.has(kind) ? t(`taskRecord.kinds.${kind.replace('.', '_')}`) : kind); const valueLabel = (key: string, value: unknown) => { if (typeof value === 'string') return PERSON_FIELDS.has(key) ? personLabel(value) : value; return typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value); }; + const locale = appDateLocale(i18n.resolvedLanguage); + const clock = (ts: unknown) => { + const date = typeof ts === 'string' ? new Date(ts) : null; + return date && !Number.isNaN(date.getTime()) ? date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null; + }; + const dayOf = (ts: string) => { + const date = new Date(ts); + return Number.isNaN(date.getTime()) ? ts : date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' }); + }; + // One mono line of the entry's own data, per kind; any other kind shows its first plain values. + const str = (value: unknown) => (typeof value === 'string' && value ? value : null); + const summary = (line: RecordLine): string => { + const d = line.data || {}; + const parts: Array = []; + switch (line.kind) { + case 'task.claimed': { const time = clock(d.leaseUntil); parts.push(time ? t('taskRecord.line.leaseUntil', { time }) : null); break; } + case 'task.released': { + const reason = str(d.reason); + parts.push(reason && t('taskRecord.line.reason', { + reason: RELEASE_REASONS.has(reason) ? t(`taskRecord.reasons.${reason}`) : spaced(reason), + })); + break; + } + case 'task.done': parts.push(str(d.doneBy) && t('taskRecord.line.doneBy', { name: personLabel(d.doneBy as string) })); break; + case 'permission.requested': { + const asked = isObject(d.askedOf) ? str(d.askedOf.id) : null; + parts.push(str(d.question), asked && t('taskRecord.line.askedOf', { name: personLabel(asked) })); + break; + } + case 'permission.decided': { + const decision = str(d.kind) || str(d.outcome); + parts.push(decision && (DECISIONS.has(decision) ? t(`taskRecord.decisions.${decision}`) : spaced(decision))); + break; + } + case 'tool.call': case 'tool.update': parts.push(str(d.name) || str(d.title), str(d.status)); break; + case 'grant.issued': parts.push(str(d.grantId)); break; + case 'grant.revoked': parts.push(str(d.reason)); break; + default: + // Plain values first, then two: an object field between them must not + // push a plain one out (attempt.settled: {attempt, failure: {…}, outcome}). + Object.entries(d) + .filter(([, value]) => typeof value === 'string' || typeof value === 'number') + .slice(0, 2) + .forEach(([key, value]) => parts.push(valueLabel(key, value))); + } + return parts.filter(Boolean).join(' · '); + }; const runId = lines[0]?.runId; const fileBase = `${taskId}.record`; + const [open, setOpen] = useState>(() => new Set()); + const toggle = (seq: number) => setOpen((now) => { + const next = new Set(now); + if (next.has(seq)) next.delete(seq); else next.add(seq); + return next; + }); + const [menuOpen, setMenuOpen] = useState(false); + // Another task's page starts closed: row seqs repeat across tasks, so an + // open row or the Download menu must not carry over. + useEffect(() => { setOpen(new Set()); setMenuOpen(false); }, [podId, taskId]); + const menuRef = useRef(null); + const menuId = useId(); + useEffect(() => { + if (!menuOpen) return undefined; + const away = (event: MouseEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false); }; + document.addEventListener('mousedown', away); + return () => document.removeEventListener('mousedown', away); + }, [menuOpen]); const downloadOtlp = async () => { setDownloadError(false); @@ -161,40 +296,107 @@ const V2TaskRecord: React.FC = () => { saveFile(JSON.stringify(otlp), 'application/json', `${fileBase}.otlp.json`); } catch { setDownloadError(true); } }; + const choose = (save: () => void) => { setMenuOpen(false); save(); }; + + // Entries grouped by the day they happened on, in record order. + const days: Array<{ day: string; entries: RecordLine[] }> = []; + lines.forEach((line) => { + const day = dayOf(line.ts); + if (days[days.length - 1]?.day === day) days[days.length - 1].entries.push(line); + else days.push({ day, entries: [line] }); + }); + const loaded = jsonl !== null; + const counts = t('taskRecord.counts', { count: lines.length }); + const retryable = error !== null && error !== 'access' && error !== 'members'; - return
- {t('taskRecord.back')} -

{t('taskRecord.title')}

-

{t('taskRecord.intro')}

- {jsonl === null && !error &&

{t('taskRecord.loading')}

} - {error &&

{t(`taskRecord.${error}`)}

- {error !== 'access' && error !== 'members' && } -
} - {jsonl !== null && <> -
- {taskId} - {t('taskRecord.counts', { count: lines.length })} - {runId && {t('taskRecord.run', { id: runId })}} -
- - -
- {downloadError &&

{t('taskRecord.downloadError')}

} -
- {!lines.length &&

{t('taskRecord.empty')}

} -
    - {lines.map((line) =>
  1. - {kindLabel(line.kind)} - {actorLabel(line.actor)} · - {t('taskRecord.entry', { number: line.seq })} - {Object.keys(line.data || {}).length > 0 &&
    - {Object.entries(line.data).map(([key, value]) => -
    {key}
    {valueLabel(key, value)}
    -
    )} -
    } -
  2. )} -
- } -
; + return
+
+ + + +

{t('taskRecord.title')}

+ {t('taskRecord.intro')} + {loaded ? `${taskId} · ${counts}` : taskId} + + {loaded &&
{ if (event.key === 'Escape') setMenuOpen(false); }}> + + {menuOpen && } +
} +
+
+ {!loaded && !error && <> +

{t('taskRecord.loading')}

+ + } + {error &&
+ + {error === 'access' &&

{t('taskRecord.unavailableTitle')}

} +

{t(`taskRecord.${error}`)}

+ {retryable && } +
} + {loaded && <> +

{title || taskId}

+

{`${taskId} · ${counts}`}

+ {downloadError &&

{t('taskRecord.downloadError')}

} + {!lines.length &&
+ +

{t('taskRecord.empty')}

+
} + {days.map(({ day, entries }) => +
{day}
+
    + {entries.map((line) => { + const isOpen = open.has(line.seq); + const moreId = `task-record-entry-${line.seq}`; + const detail = summary(line); + return
  1. + + {isOpen &&
    +
    {t('taskRecord.entryKey')}
    {line.seq}
    + {Object.entries(line.data || {}).map(([key, value]) => +
    {key}
    {key === 'argsDigest' && typeof value === 'string' && shortDigest(value) !== value + ?
    {shortDigest(value)}
    :
    {valueLabel(key, value)}
    } +
    )} +
    } +
  2. ; + })} +
+
)} + {runId &&
{t('taskRecord.run', { id: runId })}
} + } +
+
; }; export default V2TaskRecord; diff --git a/frontend/src/v2/icons/glyphs.tsx b/frontend/src/v2/icons/glyphs.tsx index 994f738cdf..f52bcc8e5e 100644 --- a/frontend/src/v2/icons/glyphs.tsx +++ b/frontend/src/v2/icons/glyphs.tsx @@ -110,3 +110,29 @@ export const ShellGlyph: React.FC<{ name: ShellGlyphName; size?: number }> = ({ return ; } }; + +export type RecordGlyphName = 'play' | 'term' | 'block' | 'key' | 'download' | 'back' | 'clock' | 'dot' | 'activity'; + +/** D215 task record (s-task-record): one mark per entry kind, plus the page's keys. */ +export const RecordGlyph: React.FC<{ name: RecordGlyphName; size?: number }> = ({ name, size }) => { + switch (name) { + case 'play': + return ; + case 'term': + return ; + case 'block': + return ; + case 'key': + return ; + case 'download': + return ; + case 'back': + return ; + case 'clock': + return ; + case 'activity': + return ; + default: + return ; + } +};