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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,13 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:

- `app.js` — routing and boot
- `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers
- `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).
- `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML)
- `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers

No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`).
`sessions.js` keeps workspace/session orchestration and message bubbles; tool cards delegate to `render/registry.js`.

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.

## Continuous integration

Expand Down
5 changes: 5 additions & 0 deletions static/js/render/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Sentinel when tool.name or result_type is missing.
* Used for fallback routing only — do not add UNKNOWN_DISPATCH_KEY to TOOL_*_RENDERERS.
*/
export const UNKNOWN_DISPATCH_KEY = 'unknown';
84 changes: 84 additions & 0 deletions static/js/render/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { renderBashUse } from './tool_use/bash.js';
import { renderReadUse } from './tool_use/read.js';
import { renderWriteUse } from './tool_use/write.js';
import { renderEditUse } from './tool_use/edit.js';
import { renderGlobUse } from './tool_use/glob.js';
import { renderGrepUse } from './tool_use/grep.js';
import { renderTaskUse } from './tool_use/task.js';
import { renderTodoWriteUse } from './tool_use/todo_write.js';
import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js';
import { renderWebFetchUse } from './tool_use/web_fetch.js';
import { renderWebSearchUse } from './tool_use/web_search.js';
import { renderToolUseFallback } from './tool_use/fallback.js';
import { getToolSummary } from './tool_use/summary.js';
import { UNKNOWN_DISPATCH_KEY } from './constants.js';

import { renderBashResult } from './tool_result/bash.js';
import { renderFileReadResult } from './tool_result/file_read.js';
import { renderFileEditResult } from './tool_result/file_edit.js';
import { renderFileWriteResult } from './tool_result/file_write.js';
import { renderGlobResult } from './tool_result/glob.js';
import { renderGrepResult } from './tool_result/grep.js';
import { renderWebSearchResult } from './tool_result/web_search.js';
import { renderWebFetchResult } from './tool_result/web_fetch.js';
import { renderTaskResult } from './tool_result/task.js';
import { renderTodoWriteResult } from './tool_result/todo_write.js';
import { renderUserInputResult } from './tool_result/user_input.js';
import { renderPlanResult } from './tool_result/plan.js';
import { renderToolResultFallback } from './tool_result/fallback.js';
import { toolResultHasBody } from './tool_result/utils.js';

export { getToolSummary, toolResultHasBody };

export const TOOL_USE_RENDERERS = {
Bash: renderBashUse,
Read: renderReadUse,
Write: renderWriteUse,
Edit: renderEditUse,
Glob: renderGlobUse,
Grep: renderGrepUse,
Task: renderTaskUse,
TodoWrite: renderTodoWriteUse,
AskUserQuestion: renderAskUserQuestionUse,
WebFetch: renderWebFetchUse,
WebSearch: renderWebSearchUse,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const TOOL_RESULT_RENDERERS = {
bash: renderBashResult,
file_read: renderFileReadResult,
file_edit: renderFileEditResult,
file_write: renderFileWriteResult,
glob: renderGlobResult,
grep: renderGrepResult,
web_search: renderWebSearchResult,
web_fetch: renderWebFetchResult,
task: renderTaskResult,
todo_write: renderTodoWriteResult,
user_input: renderUserInputResult,
plan: renderPlanResult,
};

function getToolUseRenderer(name) {
return Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name)
? TOOL_USE_RENDERERS[name]
: renderToolUseFallback;
}

function getToolResultRenderer(resultType) {
return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType)
? TOOL_RESULT_RENDERERS[resultType]
: renderToolResultFallback;
}

export function renderToolUse(tool) {
if (!tool) return '';
const name = tool.name || UNKNOWN_DISPATCH_KEY;
return getToolUseRenderer(name)(tool);
}

export function renderToolResult(parsed) {
if (!parsed) return '';
const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY;
return getToolResultRenderer(rt)(parsed);
}
Comment thread
clean6378-max-it marked this conversation as resolved.
203 changes: 203 additions & 0 deletions static/js/render/registry.test.js
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest';
import {
TOOL_USE_RENDERERS,
TOOL_RESULT_RENDERERS,
renderToolUse,
renderToolResult,
getToolSummary,
toolResultHasBody,
} from './registry.js';
import { UNKNOWN_DISPATCH_KEY } from './constants.js';
import { renderWebFetchUse } from './tool_use/web_fetch.js';

const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebFetch', 'WebSearch'];

const CORE_TOOL_RESULT = [
'bash',
'file_read',
'file_edit',
'file_write',
'glob',
'grep',
'web_search',
'web_fetch',
'task',
'todo_write',
'user_input',
'plan',
];

describe('TOOL_USE_RENDERERS', () => {
it('registers core tool names', () => {
for (const name of CORE_TOOL_USE) {
expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function');
}
});

it('does not register the unknown dispatch sentinel as a tool renderer', () => {
expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false);
});

it('renderBashUse escapes HTML in command', () => {
const html = renderToolUse({
name: 'Bash',
input: { command: '<script>alert(1)</script>' },
});
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});

it('returns empty string for null or undefined tool', () => {
expect(renderToolUse(null)).toBe('');
expect(renderToolUse(undefined)).toBe('');
});

it('renderReadUse escapes file path in body and summary', () => {
const html = renderToolUse({
name: 'Read',
input: { file_path: 'C:\\tmp\\<evil>.txt' },
});
expect(html).toContain('&lt;evil&gt;');
expect(html).not.toContain('<evil>');
});
});

describe('TOOL_RESULT_RENDERERS', () => {
it('registers core result types', () => {
for (const rt of CORE_TOOL_RESULT) {
expect(TOOL_RESULT_RENDERERS[rt], rt).toBeTypeOf('function');
}
});

it('renderBashResult escapes stdout', () => {
const html = renderToolResult({
result_type: 'bash',
exit_code: 0,
stdout: '<img onerror=alert(1)>',
});
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});

it('renderBashResult avoids undefined in summary when exit_code missing', () => {
const html = renderToolResult({ result_type: 'bash' });
expect(html).toContain('Bash Result (unknown)');
expect(html).not.toContain('undefined');
});

it('returns empty string for null or undefined parsed', () => {
expect(renderToolResult(null)).toBe('');
expect(renderToolResult(undefined)).toBe('');
});
});

describe('toolResultHasBody', () => {
it('returns false for null or undefined', () => {
expect(toolResultHasBody(null)).toBe(false);
expect(toolResultHasBody(undefined)).toBe(false);
});

it('returns true for bash with stdout or stderr', () => {
expect(toolResultHasBody({ result_type: 'bash', stdout: 'ok' })).toBe(true);
expect(toolResultHasBody({ result_type: 'bash', stderr: 'err' })).toBe(true);
expect(toolResultHasBody({ result_type: 'bash' })).toBe(false);
});

it('returns false for summary-only result types', () => {
expect(toolResultHasBody({ result_type: 'file_read', file_path: '/a' })).toBe(false);
expect(toolResultHasBody({ result_type: 'glob', num_files: 3 })).toBe(false);
});

it('returns true for user_input and todo_write with todos', () => {
expect(toolResultHasBody({ result_type: 'user_input' })).toBe(true);
expect(toolResultHasBody({ result_type: 'todo_write', todos: [{ content: 'x' }] })).toBe(true);
expect(toolResultHasBody({ result_type: 'todo_write', todo_count: 1 })).toBe(false);
});

it('returns true for task when duration, retrieval, or description is set', () => {
expect(toolResultHasBody({ result_type: 'task', description: 'subagent' })).toBe(true);
expect(toolResultHasBody({ result_type: 'task', total_duration_ms: 100 })).toBe(true);
expect(toolResultHasBody({ result_type: 'task', status: 'completed' })).toBe(false);
});
});

describe('getToolSummary', () => {
it('formats Bash summary', () => {
expect(getToolSummary('Bash', { command: 'ls -la' })).toMatch(/Bash:/);
expect(getToolSummary('Bash', { command: 'ls -la' })).toContain('ls -la');
});

it('formats Read summary as plain text (escaping deferred to wrapToolUse)', () => {
expect(getToolSummary('Read', { file_path: 'a<b' })).toBe('Read: a<b');
});
});

describe('renderToolUse fallback', () => {
it('uses JSON fallback for unknown tools', () => {
const html = renderToolUse({
name: 'UnknownToolXYZ',
input: { foo: 'bar' },
});
expect(html).toContain('tool-call');
expect(html).toContain('&quot;foo&quot;');
expect(TOOL_USE_RENDERERS.UnknownToolXYZ).toBeUndefined();
});

it('uses fallback when name is an inherited property (e.g. constructor)', () => {
const html = renderToolUse({
name: 'constructor',
input: { foo: 'bar' },
});
expect(html).toContain('tool-call');
expect(html).toContain('&quot;foo&quot;');
});

it('dispatches WebFetch to registered renderer (not generic unknown-tool fallback)', () => {
expect(TOOL_USE_RENDERERS.WebFetch).toBe(renderWebFetchUse);
const html = renderToolUse({
name: 'WebFetch',
input: { url: 'https://example.com' },
});
expect(html).toContain('tool-call');
expect(html).toContain('example.com');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('renders WebSearch via registry', () => {
const html = renderToolUse({
name: 'WebSearch',
input: { query: 'vitest registry' },
});
expect(html).toContain('tool-call');
expect(html).toContain('vitest registry');
});
});

describe('renderTodoWriteResult', () => {
it('summary count matches parsed.todos length when todos are present', () => {
const html = renderToolResult({
result_type: 'todo_write',
todo_count: 99,
todos: [
{ status: 'pending', content: 'one' },
{ status: 'completed', content: 'two' },
],
});
expect(html).toContain('Todos updated (2 items)');
expect(html).not.toContain('99 items');
});
});

describe('renderToolResult fallback', () => {
it('renders summary-only for unknown result types', () => {
const html = renderToolResult({ result_type: 'custom_type' });
expect(html).toContain('Tool result (custom_type)');
expect(html).toContain('tool-result');
});

it('uses fallback when result_type is an inherited property (e.g. constructor)', () => {
const html = renderToolResult({ result_type: 'constructor' });
expect(html).toContain('Tool result (constructor)');
expect(html).toContain('tool-result');
expect(Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, 'constructor')).toBe(false);
});
});
23 changes: 23 additions & 0 deletions static/js/render/tool_result/bash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { esc, truncate } from '../../shared/utils.js';
import { finishToolResult } from './common.js';

export function renderBashResult(parsed) {
const exitCode = parsed.exit_code;
let status;
if (parsed.interrupted) {
status = 'interrupted';
} else if (parsed.is_error) {
status = typeof exitCode === 'number' ? `error (exit ${exitCode})` : 'error';
} else if (exitCode === 0) {
status = 'success';
} else if (typeof exitCode === 'number') {
status = `exit ${exitCode}`;
} else {
status = 'unknown';
}
const summary = `Bash Result (${status})`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let body = '';
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>`;
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>`;
return finishToolResult(summary, body);
}
8 changes: 8 additions & 0 deletions static/js/render/tool_result/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { esc } from '../../shared/utils.js';

export function finishToolResult(summary, body) {
if (!body) {
return `<div class="tool-result"><span class="tool-result-summary">${esc(summary)}</span></div>`;
}
return `<details class="tool-result"><summary class="tool-result-summary">${esc(summary)}</summary><div class="tool-call-body">${body}</div></details>`;
}
8 changes: 8 additions & 0 deletions static/js/render/tool_result/fallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { finishToolResult } from './common.js';
import { UNKNOWN_DISPATCH_KEY } from '../constants.js';

export function renderToolResultFallback(parsed) {
const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY;
const summary = `Tool result (${rt})`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/file_edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderFileEditResult(parsed) {
const summary = `Edited: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
7 changes: 7 additions & 0 deletions static/js/render/tool_result/file_read.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { finishToolResult } from './common.js';

export function renderFileReadResult(parsed) {
const numLines = parsed.num_lines ? ` (${parsed.num_lines} lines)` : '';
const summary = `Read: ${parsed.file_path || ''}${numLines}`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/file_write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderFileWriteResult(parsed) {
const summary = `Wrote: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
7 changes: 7 additions & 0 deletions static/js/render/tool_result/glob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { finishToolResult } from './common.js';

export function renderGlobResult(parsed) {
const trunc = parsed.truncated ? ' (truncated)' : '';
const summary = `Glob: ${parsed.num_files || 0} files found${trunc}`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/grep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderGrepResult(parsed) {
const summary = `Grep: ${parsed.num_files || 0} files, ${parsed.num_lines || 0} lines`;
return finishToolResult(summary, '');
}
6 changes: 6 additions & 0 deletions static/js/render/tool_result/plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { finishToolResult } from './common.js';

export function renderPlanResult(parsed) {
const summary = `Plan: ${parsed.file_path || ''}`;
return finishToolResult(summary, '');
}
Loading
Loading