Skip to content

Commit 512e71c

Browse files
refactor(frontend): decompose sessions.js tool rendering into dispatch registry
Extract tool use and tool result rendering from sessions.js into static/js/render/ with TOOL_USE_RENDERERS and TOOL_RESULT_RENDERERS, one module per tool type, mirroring utils/tool_dispatch.py. sessions.js retains workspace and message orchestration only. Add Vitest coverage for registry wiring, HTML escaping, and getToolSummary. Document the frontend registry in docs/architecture.md. No intended visual or behavior change for existing sessions.
1 parent 4b2a4c6 commit 512e71c

31 files changed

Lines changed: 443 additions & 142 deletions

docs/architecture.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,13 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:
105105

106106
- `app.js` — routing and boot
107107
- `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers
108+
- `render/registry.js`**tool dispatch registry** for session UI: `TOOL_USE_RENDERERS` and `TOOL_RESULT_RENDERERS` map tool name / `result_type` → render function (one module per type under `render/tool_use/` and `render/tool_result/`). Parallels backend `utils/tool_dispatch.py` (backend uses ordered predicates; frontend uses direct key lookup + fallback).
108109
- `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML)
109110
- `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers
110111

111-
No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`).
112+
`sessions.js` keeps workspace/session orchestration and message bubbles; tool cards delegate to `render/registry.js`.
113+
114+
No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping.
112115

113116
## Continuous integration
114117

static/js/render/registry.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { renderBashUse } from './tool_use/bash.js';
2+
import { renderReadUse } from './tool_use/read.js';
3+
import { renderWriteUse } from './tool_use/write.js';
4+
import { renderEditUse } from './tool_use/edit.js';
5+
import { renderGlobUse } from './tool_use/glob.js';
6+
import { renderGrepUse } from './tool_use/grep.js';
7+
import { renderTaskUse } from './tool_use/task.js';
8+
import { renderTodoWriteUse } from './tool_use/todo_write.js';
9+
import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js';
10+
import { renderToolUseFallback } from './tool_use/fallback.js';
11+
import { getToolSummary } from './tool_use/summary.js';
12+
13+
import { renderBashResult } from './tool_result/bash.js';
14+
import { renderFileReadResult } from './tool_result/file_read.js';
15+
import { renderFileEditResult } from './tool_result/file_edit.js';
16+
import { renderFileWriteResult } from './tool_result/file_write.js';
17+
import { renderGlobResult } from './tool_result/glob.js';
18+
import { renderGrepResult } from './tool_result/grep.js';
19+
import { renderWebSearchResult } from './tool_result/web_search.js';
20+
import { renderWebFetchResult } from './tool_result/web_fetch.js';
21+
import { renderTaskResult } from './tool_result/task.js';
22+
import { renderTodoWriteResult } from './tool_result/todo_write.js';
23+
import { renderUserInputResult } from './tool_result/user_input.js';
24+
import { renderPlanResult } from './tool_result/plan.js';
25+
import { renderToolResultFallback } from './tool_result/fallback.js';
26+
import { toolResultHasBody } from './tool_result/utils.js';
27+
28+
export { getToolSummary, toolResultHasBody };
29+
30+
export const TOOL_USE_RENDERERS = {
31+
Bash: renderBashUse,
32+
Read: renderReadUse,
33+
Write: renderWriteUse,
34+
Edit: renderEditUse,
35+
Glob: renderGlobUse,
36+
Grep: renderGrepUse,
37+
Task: renderTaskUse,
38+
TodoWrite: renderTodoWriteUse,
39+
AskUserQuestion: renderAskUserQuestionUse,
40+
};
41+
42+
export const TOOL_RESULT_RENDERERS = {
43+
bash: renderBashResult,
44+
file_read: renderFileReadResult,
45+
file_edit: renderFileEditResult,
46+
file_write: renderFileWriteResult,
47+
glob: renderGlobResult,
48+
grep: renderGrepResult,
49+
web_search: renderWebSearchResult,
50+
web_fetch: renderWebFetchResult,
51+
task: renderTaskResult,
52+
todo_write: renderTodoWriteResult,
53+
user_input: renderUserInputResult,
54+
plan: renderPlanResult,
55+
};
56+
57+
export function renderToolUse(tool) {
58+
const name = tool.name || 'unknown';
59+
const fn = TOOL_USE_RENDERERS[name] ?? renderToolUseFallback;
60+
return fn(tool);
61+
}
62+
63+
export function renderToolResult(parsed) {
64+
const rt = parsed.result_type || 'unknown';
65+
const fn = TOOL_RESULT_RENDERERS[rt] ?? renderToolResultFallback;
66+
return fn(parsed);
67+
}

static/js/render/registry.test.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
TOOL_USE_RENDERERS,
4+
TOOL_RESULT_RENDERERS,
5+
renderToolUse,
6+
renderToolResult,
7+
getToolSummary,
8+
} from './registry.js';
9+
10+
const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion'];
11+
12+
const CORE_TOOL_RESULT = [
13+
'bash',
14+
'file_read',
15+
'file_edit',
16+
'file_write',
17+
'glob',
18+
'grep',
19+
'web_search',
20+
'web_fetch',
21+
'task',
22+
'todo_write',
23+
'user_input',
24+
'plan',
25+
];
26+
27+
describe('TOOL_USE_RENDERERS', () => {
28+
it('registers core tool names', () => {
29+
for (const name of CORE_TOOL_USE) {
30+
expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function');
31+
}
32+
});
33+
34+
it('renderBashUse escapes HTML in command', () => {
35+
const html = renderToolUse({
36+
name: 'Bash',
37+
input: { command: '<script>alert(1)</script>' },
38+
});
39+
expect(html).not.toContain('<script>');
40+
expect(html).toContain('&lt;script&gt;');
41+
});
42+
43+
it('renderReadUse escapes file path in body', () => {
44+
const html = renderToolUse({
45+
name: 'Read',
46+
input: { file_path: 'C:\\tmp\\<evil>.txt' },
47+
});
48+
expect(html).toContain('&lt;evil&gt;');
49+
expect(html).not.toContain('<evil>');
50+
});
51+
});
52+
53+
describe('TOOL_RESULT_RENDERERS', () => {
54+
it('registers core result types', () => {
55+
for (const rt of CORE_TOOL_RESULT) {
56+
expect(TOOL_RESULT_RENDERERS[rt], rt).toBeTypeOf('function');
57+
}
58+
});
59+
60+
it('renderBashResult escapes stdout', () => {
61+
const html = renderToolResult({
62+
result_type: 'bash',
63+
exit_code: 0,
64+
stdout: '<img onerror=alert(1)>',
65+
});
66+
expect(html).not.toContain('<img');
67+
expect(html).toContain('&lt;img');
68+
});
69+
});
70+
71+
describe('getToolSummary', () => {
72+
it('formats Bash summary', () => {
73+
expect(getToolSummary('Bash', { command: 'ls -la' })).toMatch(/Bash:/);
74+
expect(getToolSummary('Bash', { command: 'ls -la' })).toContain('ls -la');
75+
});
76+
77+
it('formats Read summary with escaped path', () => {
78+
expect(getToolSummary('Read', { file_path: 'a<b' })).toContain('&lt;b');
79+
});
80+
});
81+
82+
describe('renderToolUse fallback', () => {
83+
it('uses JSON fallback for unknown tools', () => {
84+
const html = renderToolUse({
85+
name: 'WebFetch',
86+
input: { url: 'https://example.com' },
87+
});
88+
expect(html).toContain('tool-call');
89+
expect(html).toContain('example.com');
90+
});
91+
});
92+
93+
describe('renderToolResult fallback', () => {
94+
it('renders summary-only for unknown result types', () => {
95+
const html = renderToolResult({ result_type: 'custom_type' });
96+
expect(html).toContain('Tool result (custom_type)');
97+
expect(html).toContain('tool-result');
98+
});
99+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { esc, truncate } from '../../shared/utils.js';
2+
import { finishToolResult } from './common.js';
3+
4+
export function renderBashResult(parsed) {
5+
const exitCode = parsed.exit_code;
6+
const status = parsed.interrupted ? 'interrupted' : (parsed.is_error ? `error (exit ${exitCode})` : (exitCode === 0 ? 'success' : `exit ${exitCode}`));
7+
const summary = `Bash Result (${status})`;
8+
let body = '';
9+
if (parsed.stdout) body += `<div class="tool-call-section"><div class="tool-call-section-title">stdout</div><pre><code>${esc(truncate(parsed.stdout, 2000))}</code></pre></div>`;
10+
if (parsed.stderr) body += `<div class="tool-call-section"><div class="tool-call-section-title">stderr</div><pre style="border-left:3px solid var(--danger)"><code>${esc(truncate(parsed.stderr, 1000))}</code></pre></div>`;
11+
return finishToolResult(summary, body);
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { esc } from '../../shared/utils.js';
2+
3+
export function finishToolResult(summary, body) {
4+
if (!body) {
5+
return `<div class="tool-result"><span class="tool-result-summary">${esc(summary)}</span></div>`;
6+
}
7+
return `<details class="tool-result"><summary class="tool-result-summary">${esc(summary)}</summary><div class="tool-call-body">${body}</div></details>`;
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { finishToolResult } from './common.js';
2+
3+
export function renderToolResultFallback(parsed) {
4+
const rt = parsed.result_type || 'unknown';
5+
const summary = `Tool result (${rt})`;
6+
return finishToolResult(summary, '');
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { finishToolResult } from './common.js';
2+
3+
export function renderFileEditResult(parsed) {
4+
const summary = `Edited: ${parsed.file_path || ''}`;
5+
return finishToolResult(summary, '');
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { finishToolResult } from './common.js';
2+
3+
export function renderFileReadResult(parsed) {
4+
const numLines = parsed.num_lines ? ` (${parsed.num_lines} lines)` : '';
5+
const summary = `Read: ${parsed.file_path || ''}${numLines}`;
6+
return finishToolResult(summary, '');
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { finishToolResult } from './common.js';
2+
3+
export function renderFileWriteResult(parsed) {
4+
const summary = `Wrote: ${parsed.file_path || ''}`;
5+
return finishToolResult(summary, '');
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { finishToolResult } from './common.js';
2+
3+
export function renderGlobResult(parsed) {
4+
const trunc = parsed.truncated ? ' (truncated)' : '';
5+
const summary = `Glob: ${parsed.num_files || 0} files found${trunc}`;
6+
return finishToolResult(summary, '');
7+
}

0 commit comments

Comments
 (0)