From 322138df51c3d965e52d4be7477e596332195479 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:24:33 -0500 Subject: [PATCH] refactor(desktop): split tool fallback-model into a folder with leaf modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fallback-model.ts (1,696) folded into assistant-ui/tool/fallback-model/ with three cohesive, self-contained leaf modules extracted (verbatim moves): - types.ts (83) — the shared tool-view types/interfaces. - format.ts (133) — pure value formatting/parsing (isRecord, compactPreview, clampForDisplay, prettyJson, parseMaybeObject, unwrapToolPayload, numberValue, contextValue, formatDurationSeconds). - targets.ts (75) — url/path/preview detection + disclosure ids (looksLikeUrl, findFirstUrl, hostnameOf, isPreviewableTarget, toolPart/GroupDisclosureId). index.ts (1,434) keeps the tool-specific assembler (TOOL_META, titles, the count machinery, subtitle/detail/diff, buildToolView) and re-exports the leaf modules, so consumers importing `./fallback-model` are unchanged (folder index resolution) — no importer or channel edits needed. The count/result/detail helpers reach across each other around buildToolView, so they stay together to avoid a circular split; the three leaves are the clean cut. Behaviour-preserving: typecheck 0 errors, eslint clean, fallback-model test 24/26 (the 2 browser_navigate title failures are pre-existing on main — `hostnameOf` intentionally includes the pathname; verified identical on the un-split file). --- .../tool/fallback-model/format.ts | 133 +++++++ .../index.ts} | 328 ++---------------- .../tool/fallback-model/targets.ts | 75 ++++ .../assistant-ui/tool/fallback-model/types.ts | 83 +++++ 4 files changed, 324 insertions(+), 295 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts rename apps/desktop/src/components/assistant-ui/tool/{fallback-model.ts => fallback-model/index.ts} (84%) create mode 100644 apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts create mode 100644 apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts new file mode 100644 index 00000000000..c9a5c57fee3 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts @@ -0,0 +1,133 @@ + +export function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +export function compactPreview(value: unknown, max = 72): string { + let raw: unknown + + if (typeof value === 'string') { + raw = value + } else { + raw = parseMaybeObject(value).context + } + + if (typeof raw !== 'string') { + if (raw == null) { + raw = '' + } else { + try { + raw = JSON.stringify(raw) + } catch { + raw = String(raw) + } + } + } + + const line = (raw as string).replace(/\s+/g, ' ').trim() + + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +export function contextValue(value: unknown): string { + const row = parseMaybeObject(value) + + if (typeof row.context === 'string') { + return row.context + } + + if (typeof row.preview === 'string') { + return row.preview + } + + return typeof value === 'string' ? value : '' +} + +// Each tool result is server-capped (~100KB), but a turn over a big directory +// stacks many rows; painting/serializing them all floods the renderer (freeze, +// then OOM). Clamp every inline-painted payload to a bounded slice — the row's +// Copy button still reads the uncapped `view.detail` for the full output. +export const MAX_TOOL_RENDER_CHARS = 20_000 + +export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string { + if (value.length <= max) { + return value + } + + const omitted = value.length - max + + return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.` +} + +export function prettyJson(value: unknown): string { + const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + + return clampForDisplay(raw ?? '') +} + +export function parseMaybeObject(value: unknown): Record { + if (isRecord(value)) { + return value + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +export function unwrapToolPayload(value: unknown): unknown { + const record = parseMaybeObject(value) + + for (const key of ['data', 'result', 'output', 'response', 'payload']) { + const payload = record[key] + + if (payload !== undefined && payload !== null) { + return payload + } + } + + return value +} + +export function numberValue(value: unknown): null | number { + const n = typeof value === 'number' ? value : Number(value) + + return Number.isFinite(n) ? n : null +} + +export function formatDurationSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '' + } + + if (seconds < 1) { + const ms = Math.max(1, Math.round(seconds * 1000)) + + return `${ms}ms` + } + + if (seconds < 60) { + return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` + } + + const wholeSeconds = Math.round(seconds) + const minutes = Math.floor(wholeSeconds / 60) + const remSeconds = wholeSeconds % 60 + + if (minutes < 60) { + return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m` + } + + const hours = Math.floor(minutes / 60) + const remMinutes = minutes % 60 + + return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h` +} diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts similarity index 84% rename from apps/desktop/src/components/assistant-ui/tool/fallback-model.ts rename to apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index b99527f05c4..62d6faccf42 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -3,88 +3,38 @@ import { normalizeExternalUrl } from '@/lib/external-link' import { summarizeShellCommand } from '@/lib/summarize-command' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' -export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' -export type ToolStatus = 'error' | 'running' | 'success' | 'warning' +import { + compactPreview, + contextValue, + formatDurationSeconds, + isRecord, + numberValue, + parseMaybeObject, + prettyJson, + unwrapToolPayload +} from './format' +import { + findFirstUrl, + hostnameOf, + looksLikePath, + looksLikeUrl +} from './targets' +import type { + CountMetric, + MessageRunningStateSlice, + SearchResultRow, + ToolMeta, + ToolMetaSpec, + ToolPart, + ToolStatus, + ToolTitleAction, + ToolTone, + ToolView +} from './types' -export interface ToolPart { - args?: unknown - isError?: boolean - result?: unknown - toolCallId?: string - toolName: string - type: 'tool-call' -} - -export interface SearchResultRow { - snippet: string - title: string - url: string -} - -export interface ToolTitleAction { - prefix: string - suffix: string - text: string -} - -interface CountMetric { - count: number - noun: string -} - -export interface ToolView { - countLabel?: string - detail: string - detailLabel: string - durationLabel?: string - icon?: string - imageUrl?: string - inlineDiff: string - previewTarget?: string - rawArgs: string - rawResult: string - /** Set for tools whose output naturally contains ANSI escape codes - * (terminal/execute_code) so the renderer knows to run them through - * the ANSI parser instead of printing them as literals. */ - rendersAnsi?: boolean - searchHits?: SearchResultRow[] - /** When the backend reports stderr as a separate stream (terminal / - * execute_code), the renderer shows it as its own labeled, neutrally - * tinted block under stdout — distinct from an error tone. */ - stderr?: string - /** When set, the renderer uses stdout+stderr as separate sections and - * ignores the merged `detail`. */ - stdout?: string - status: ToolStatus - subtitle: string - title: string - titleAction?: ToolTitleAction - tone: ToolTone -} - -interface ToolMeta { - done: string - icon?: string - pending: string - pendingAction: string - tone: ToolTone -} - -interface ToolMetaSpec { - icon?: string - tone: ToolTone -} - -export interface MessageRunningStateSlice { - message: { - status?: { - type?: string - } - } - thread: { - isRunning: boolean - } -} +export * from './format' +export * from './targets' +export * from './types' const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file']) @@ -317,139 +267,6 @@ function toolMeta(name: string): ToolMeta { } } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) -} - -export function compactPreview(value: unknown, max = 72): string { - let raw: unknown - - if (typeof value === 'string') { - raw = value - } else { - raw = parseMaybeObject(value).context - } - - if (typeof raw !== 'string') { - if (raw == null) { - raw = '' - } else { - try { - raw = JSON.stringify(raw) - } catch { - raw = String(raw) - } - } - } - - const line = (raw as string).replace(/\s+/g, ' ').trim() - - return line.length > max ? `${line.slice(0, max - 1)}…` : line -} - -function contextValue(value: unknown): string { - const row = parseMaybeObject(value) - - if (typeof row.context === 'string') { - return row.context - } - - if (typeof row.preview === 'string') { - return row.preview - } - - return typeof value === 'string' ? value : '' -} - -// Each tool result is server-capped (~100KB), but a turn over a big directory -// stacks many rows; painting/serializing them all floods the renderer (freeze, -// then OOM). Clamp every inline-painted payload to a bounded slice — the row's -// Copy button still reads the uncapped `view.detail` for the full output. -export const MAX_TOOL_RENDER_CHARS = 20_000 - -export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string { - if (value.length <= max) { - return value - } - - const omitted = value.length - max - - return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.` -} - -function prettyJson(value: unknown): string { - const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2) - - return clampForDisplay(raw ?? '') -} - -function parseMaybeObject(value: unknown): Record { - if (isRecord(value)) { - return value - } - - if (typeof value !== 'string' || !value.trim()) { - return {} - } - - try { - const parsed = JSON.parse(value) - - return isRecord(parsed) ? parsed : {} - } catch { - return {} - } -} - -function unwrapToolPayload(value: unknown): unknown { - const record = parseMaybeObject(value) - - for (const key of ['data', 'result', 'output', 'response', 'payload']) { - const payload = record[key] - - if (payload !== undefined && payload !== null) { - return payload - } - } - - return value -} - -function numberValue(value: unknown): null | number { - const n = typeof value === 'number' ? value : Number(value) - - return Number.isFinite(n) ? n : null -} - -function formatDurationSeconds(seconds: number): string { - if (!Number.isFinite(seconds) || seconds < 0) { - return '' - } - - if (seconds < 1) { - const ms = Math.max(1, Math.round(seconds * 1000)) - - return `${ms}ms` - } - - if (seconds < 60) { - return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` - } - - const wholeSeconds = Math.round(seconds) - const minutes = Math.floor(wholeSeconds / 60) - const remSeconds = wholeSeconds % 60 - - if (minutes < 60) { - return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m` - } - - const hours = Math.floor(minutes / 60) - const remMinutes = minutes % 60 - - return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h` -} - const COUNT_FIELD_KEYS = [ 'count', 'total', @@ -719,79 +536,6 @@ function toolResultCount( return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null } -function looksLikeUrl(value: string): boolean { - return /^https?:\/\//i.test(value) -} - -function looksLikePath(value: string): boolean { - return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) -} - -export function isPreviewableTarget(target: string): boolean { - return Boolean( - target && - (/^file:\/\//i.test(target) || - /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || - /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) - ) -} - -function stableHash(value: string): string { - let hash = 0 - - for (let index = 0; index < value.length; index += 1) { - hash = Math.imul(31, hash) + value.charCodeAt(index) - } - - return Math.abs(hash).toString(36) -} - -export function toolPartDisclosureId(part: ToolPart): string { - if (part.toolCallId) { - return `tool:${part.toolCallId}` - } - - return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}` -} - -export function toolGroupDisclosureId(parts: ToolPart[]): string { - return `tool-group:${parts.map(toolPartDisclosureId).join('|')}` -} - -const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i - -function findFirstUrl(...sources: unknown[]): string { - for (const src of sources) { - if (typeof src === 'string') { - const m = src.match(URL_PATTERN) - - if (m) { - return m[0] - } - } else if (src && typeof src === 'object') { - for (const v of Object.values(src as Record)) { - const found = findFirstUrl(v) - - if (found) { - return found - } - } - } - } - - return '' -} - -function hostnameOf(value: string): string { - try { - const url = new URL(value) - - return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` - } catch { - return value - } -} - export function looksRedundant(title: string, detail: string): boolean { if (!detail) { return true @@ -1540,10 +1284,7 @@ function dynamicTitle( } const failed = - part.isError || - result.success === false || - result.ok === false || - Boolean(firstStringField(result, ['error'])) + part.isError || result.success === false || result.ok === false || Boolean(firstStringField(result, ['error'])) if (failed) { const failAction = translateNow('assistant.tool.actions.failedToOpen') @@ -1556,10 +1297,7 @@ function dynamicTitle( const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened')) - return titledAction( - action, - translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)) - ) + return titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url))) } if (part.toolName === 'web_search') { diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts new file mode 100644 index 00000000000..07e9a2c200f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts @@ -0,0 +1,75 @@ + +import type { ToolPart } from './types' + +export function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +export function looksLikePath(value: string): boolean { + return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) +} + +export function isPreviewableTarget(target: string): boolean { + return Boolean( + target && + (/^file:\/\//i.test(target) || + /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || + /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) + ) +} + +export function stableHash(value: string): string { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = Math.imul(31, hash) + value.charCodeAt(index) + } + + return Math.abs(hash).toString(36) +} + +export function toolPartDisclosureId(part: ToolPart): string { + if (part.toolCallId) { + return `tool:${part.toolCallId}` + } + + return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}` +} + +export function toolGroupDisclosureId(parts: ToolPart[]): string { + return `tool-group:${parts.map(toolPartDisclosureId).join('|')}` +} + +export const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i + +export function findFirstUrl(...sources: unknown[]): string { + for (const src of sources) { + if (typeof src === 'string') { + const m = src.match(URL_PATTERN) + + if (m) { + return m[0] + } + } else if (src && typeof src === 'object') { + for (const v of Object.values(src as Record)) { + const found = findFirstUrl(v) + + if (found) { + return found + } + } + } + } + + return '' +} + +export function hostnameOf(value: string): string { + try { + const url = new URL(value) + + return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` + } catch { + return value + } +} diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts new file mode 100644 index 00000000000..a6969eee826 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts @@ -0,0 +1,83 @@ + +export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' +export type ToolStatus = 'error' | 'running' | 'success' | 'warning' + +export interface ToolPart { + args?: unknown + isError?: boolean + result?: unknown + toolCallId?: string + toolName: string + type: 'tool-call' +} + +export interface SearchResultRow { + snippet: string + title: string + url: string +} + +export interface ToolTitleAction { + prefix: string + suffix: string + text: string +} + +export interface CountMetric { + count: number + noun: string +} + +export interface ToolView { + countLabel?: string + detail: string + detailLabel: string + durationLabel?: string + icon?: string + imageUrl?: string + inlineDiff: string + previewTarget?: string + rawArgs: string + rawResult: string + /** Set for tools whose output naturally contains ANSI escape codes + * (terminal/execute_code) so the renderer knows to run them through + * the ANSI parser instead of printing them as literals. */ + rendersAnsi?: boolean + searchHits?: SearchResultRow[] + /** When the backend reports stderr as a separate stream (terminal / + * execute_code), the renderer shows it as its own labeled, neutrally + * tinted block under stdout — distinct from an error tone. */ + stderr?: string + /** When set, the renderer uses stdout+stderr as separate sections and + * ignores the merged `detail`. */ + stdout?: string + status: ToolStatus + subtitle: string + title: string + titleAction?: ToolTitleAction + tone: ToolTone +} + +export interface ToolMeta { + done: string + icon?: string + pending: string + pendingAction: string + tone: ToolTone +} + +export interface ToolMetaSpec { + icon?: string + tone: ToolTone +} + +export interface MessageRunningStateSlice { + message: { + status?: { + type?: string + } + } + thread: { + isRunning: boolean + } +}