@neurocode-ai/session-ui 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,2662 @@
1
+ import {
2
+ Component,
3
+ createEffect,
4
+ createMemo,
5
+ createSignal,
6
+ For,
7
+ Match,
8
+ onMount,
9
+ Show,
10
+ Switch,
11
+ onCleanup,
12
+ Index,
13
+ type JSX,
14
+ type ComponentProps,
15
+ } from "solid-js"
16
+ import { createStore } from "solid-js/store"
17
+ import stripAnsi from "strip-ansi"
18
+ import { Dynamic } from "solid-js/web"
19
+ import {
20
+ AgentPart,
21
+ AssistantMessage,
22
+ FilePart,
23
+ Message as MessageType,
24
+ Part as PartType,
25
+ ReasoningPart,
26
+ Session,
27
+ TextPart,
28
+ ToolPart,
29
+ UserMessage,
30
+ Todo,
31
+ QuestionAnswer,
32
+ QuestionInfo,
33
+ } from "@neurocode-ai/sdk/v2"
34
+ import { useData } from "../context"
35
+ import { useFileComponent } from "@neurocode-ai/ui/context/file"
36
+ import { useDialog } from "@neurocode-ai/ui/context/dialog"
37
+ import { type UiI18n, useI18n } from "@neurocode-ai/ui/context/i18n"
38
+ import { BasicTool, GenericTool } from "./basic-tool"
39
+ import { Accordion } from "@neurocode-ai/ui/accordion"
40
+ import { StickyAccordionHeader } from "@neurocode-ai/ui/sticky-accordion-header"
41
+ import { Collapsible } from "@neurocode-ai/ui/collapsible"
42
+ import { FileIcon } from "@neurocode-ai/ui/file-icon"
43
+ import { Icon } from "@neurocode-ai/ui/icon"
44
+ import { ToolErrorCard } from "./tool-error-card"
45
+ import { Checkbox } from "@neurocode-ai/ui/checkbox"
46
+ import { DiffChanges } from "@neurocode-ai/ui/diff-changes"
47
+ import { Markdown } from "./markdown"
48
+ import { ImagePreview } from "@neurocode-ai/ui/image-preview"
49
+ import { getDirectory as _getDirectory, getFilename } from "@neurocode-ai/core/util/path"
50
+ import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
51
+ import { CommentCardV2 } from "../v2/components/comment-card-v2"
52
+ import { checksum } from "@neurocode-ai/core/util/encode"
53
+ import { Tooltip } from "@neurocode-ai/ui/tooltip"
54
+ import { IconButton } from "@neurocode-ai/ui/icon-button"
55
+ import { Icon as IconV2 } from "@neurocode-ai/ui/v2/icon"
56
+ import { IconButtonV2 } from "@neurocode-ai/ui/v2/icon-button-v2"
57
+ import { ButtonV2 } from "@neurocode-ai/ui/v2/button-v2"
58
+ import { TooltipV2 } from "@neurocode-ai/ui/v2/tooltip-v2"
59
+ import { Spinner } from "@neurocode-ai/ui/spinner"
60
+ import { TextShimmer } from "@neurocode-ai/ui/text-shimmer"
61
+ import { AnimatedCountList } from "./tool-count-summary"
62
+ import { ToolStatusTitle } from "./tool-status-title"
63
+ import { patchFiles } from "./apply-patch-file"
64
+ import { animate } from "motion"
65
+ import { useLocation } from "@solidjs/router"
66
+ import { attached, inline, kind, typeLabel } from "./message-file"
67
+ import { readPartText } from "./message-part-text"
68
+ import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
69
+
70
+ async function writeClipboard(text: string): Promise<boolean> {
71
+ const body = typeof document === "undefined" ? undefined : document.body
72
+ if (body) {
73
+ const textarea = document.createElement("textarea")
74
+ textarea.value = text
75
+ textarea.setAttribute("readonly", "")
76
+ textarea.style.position = "fixed"
77
+ textarea.style.opacity = "0"
78
+ textarea.style.pointerEvents = "none"
79
+ body.appendChild(textarea)
80
+ textarea.select()
81
+ const copied = document.execCommand("copy")
82
+ body.removeChild(textarea)
83
+ if (copied) return true
84
+ }
85
+
86
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
87
+ if (!clipboard?.writeText) return false
88
+ return clipboard.writeText(text).then(
89
+ () => true,
90
+ () => false,
91
+ )
92
+ }
93
+
94
+ function ShellSubmessage(props: { text: string; animate?: boolean }) {
95
+ let widthRef: HTMLSpanElement | undefined
96
+ let valueRef: HTMLSpanElement | undefined
97
+
98
+ onMount(() => {
99
+ if (!props.animate) return
100
+ requestAnimationFrame(() => {
101
+ if (widthRef) {
102
+ animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
103
+ }
104
+ if (valueRef) {
105
+ animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
106
+ }
107
+ })
108
+ })
109
+
110
+ return (
111
+ <span data-component="shell-submessage">
112
+ <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
113
+ <span data-slot="basic-tool-tool-subtitle">
114
+ <span
115
+ ref={valueRef}
116
+ data-slot="shell-submessage-value"
117
+ style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
118
+ >
119
+ {props.text}
120
+ </span>
121
+ </span>
122
+ </span>
123
+ </span>
124
+ )
125
+ }
126
+
127
+ interface Diagnostic {
128
+ range: {
129
+ start: { line: number; character: number }
130
+ end: { line: number; character: number }
131
+ }
132
+ message: string
133
+ severity?: number
134
+ }
135
+
136
+ function getDiagnostics(
137
+ diagnosticsByFile: Record<string, Diagnostic[]> | undefined,
138
+ filePath: string | undefined,
139
+ ): Diagnostic[] {
140
+ if (!diagnosticsByFile || !filePath) return []
141
+ const diagnostics = diagnosticsByFile[filePath] ?? []
142
+ return diagnostics.filter((d) => d.severity === 1).slice(0, 3)
143
+ }
144
+
145
+ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
146
+ const i18n = useI18n()
147
+ return (
148
+ <Show when={props.diagnostics.length > 0}>
149
+ <div data-component="diagnostics">
150
+ <For each={props.diagnostics}>
151
+ {(diagnostic) => (
152
+ <div data-slot="diagnostic">
153
+ <span data-slot="diagnostic-label">{i18n.t("ui.messagePart.diagnostic.error")}</span>
154
+ <span data-slot="diagnostic-location">
155
+ [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]
156
+ </span>
157
+ <span data-slot="diagnostic-message">{diagnostic.message}</span>
158
+ </div>
159
+ )}
160
+ </For>
161
+ </div>
162
+ </Show>
163
+ )
164
+ }
165
+
166
+ export interface MessageProps {
167
+ message: MessageType
168
+ parts: PartType[]
169
+ actions?: UserActions
170
+ showAssistantCopyPartID?: string | null
171
+ showReasoningSummaries?: boolean
172
+ useV2Actions?: boolean
173
+ comments?: UserMessageComment[]
174
+ }
175
+
176
+ export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
177
+
178
+ export type UserActions = {
179
+ fork?: SessionAction
180
+ revert?: SessionAction
181
+ openAttachment?: (file: FilePart) => void
182
+ }
183
+
184
+ export type UserMessageComment = {
185
+ path: string
186
+ comment: string
187
+ selection?: {
188
+ startLine: number
189
+ endLine: number
190
+ }
191
+ }
192
+
193
+ export interface MessagePartProps {
194
+ part: PartType
195
+ message: MessageType
196
+ hideDetails?: boolean
197
+ defaultOpen?: boolean
198
+ toolOpen?: boolean
199
+ onToolOpenChange?: (open: boolean) => void
200
+ deferToolContent?: boolean
201
+ virtualizeDiff?: boolean
202
+ onContentRendered?: () => void
203
+ showAssistantCopyPartID?: string | null
204
+ turnDurationMs?: number
205
+ useV2Actions?: boolean
206
+ }
207
+
208
+ function MessageActionButton(
209
+ props: Pick<ComponentProps<"button">, "disabled" | "onMouseDown" | "onClick" | "aria-label"> & {
210
+ icon: "check" | "copy" | "reset"
211
+ label: JSX.Element
212
+ useV2?: boolean
213
+ },
214
+ ) {
215
+ const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon)
216
+ return (
217
+ <Show
218
+ when={props.useV2}
219
+ fallback={
220
+ <Tooltip value={props.label} placement="top" gutter={4}>
221
+ <IconButton
222
+ icon={props.icon}
223
+ size="normal"
224
+ variant="ghost"
225
+ disabled={props.disabled}
226
+ onMouseDown={props.onMouseDown}
227
+ onClick={props.onClick}
228
+ aria-label={props["aria-label"]}
229
+ />
230
+ </Tooltip>
231
+ }
232
+ >
233
+ <TooltipV2 value={props.label} placement="top" gutter={4}>
234
+ <IconButtonV2
235
+ icon={<IconV2 name={icon()} size="small" />}
236
+ size="normal"
237
+ variant="ghost-muted"
238
+ disabled={props.disabled}
239
+ onMouseDown={props.onMouseDown}
240
+ onClick={props.onClick}
241
+ aria-label={props["aria-label"]}
242
+ />
243
+ </TooltipV2>
244
+ </Show>
245
+ )
246
+ }
247
+
248
+ export type PartComponent = Component<MessagePartProps>
249
+
250
+ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
251
+
252
+ const TEXT_RENDER_PACE_MS = 24
253
+ const TEXT_RENDER_IMMEDIATE = 512
254
+ const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
255
+
256
+ function step(size: number) {
257
+ if (size <= 12) return 2
258
+ if (size <= 48) return 4
259
+ if (size <= 96) return 8
260
+ return Math.min(256, Math.ceil(size / 4))
261
+ }
262
+
263
+ function next(text: string, start: number) {
264
+ const end = Math.min(text.length, start + step(text.length - start))
265
+ const max = Math.min(text.length, end + 8)
266
+ for (let i = end; i < max; i++) {
267
+ if (TEXT_RENDER_SNAP.test(text[i] ?? "")) return i + 1
268
+ }
269
+ return end
270
+ }
271
+
272
+ function createPacedValue(getValue: () => string, live?: () => boolean) {
273
+ const [value, setValue] = createSignal(getValue())
274
+ let shown = getValue()
275
+ let timeout: ReturnType<typeof setTimeout> | undefined
276
+
277
+ const clear = () => {
278
+ if (!timeout) return
279
+ clearTimeout(timeout)
280
+ timeout = undefined
281
+ }
282
+
283
+ const sync = (text: string) => {
284
+ shown = text
285
+ setValue(text)
286
+ }
287
+
288
+ const run = () => {
289
+ timeout = undefined
290
+ const text = getValue()
291
+ if (!live?.()) {
292
+ sync(text)
293
+ return
294
+ }
295
+ if (!text.startsWith(shown) || text.length <= shown.length) {
296
+ sync(text)
297
+ return
298
+ }
299
+ if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
300
+ sync(text)
301
+ return
302
+ }
303
+ const end = next(text, shown.length)
304
+ sync(text.slice(0, end))
305
+ if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
306
+ }
307
+
308
+ createEffect(() => {
309
+ const text = getValue()
310
+ if (!live?.()) {
311
+ clear()
312
+ sync(text)
313
+ return
314
+ }
315
+ if (!text.startsWith(shown) || text.length < shown.length) {
316
+ clear()
317
+ sync(text)
318
+ return
319
+ }
320
+ if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
321
+ clear()
322
+ sync(text)
323
+ return
324
+ }
325
+ if (text.length === shown.length || timeout) return
326
+ timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
327
+ })
328
+
329
+ onCleanup(() => {
330
+ clear()
331
+ })
332
+
333
+ return value
334
+ }
335
+
336
+ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) {
337
+ const value = createPacedValue(
338
+ () => props.text,
339
+ () => props.streaming,
340
+ )
341
+
342
+ return (
343
+ <Show when={value()}>
344
+ <Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
345
+ </Show>
346
+ )
347
+ }
348
+
349
+ function relativizeProjectPath(path: string, directory?: string) {
350
+ if (!path) return ""
351
+ if (!directory) return path
352
+ if (directory === "/") return path
353
+ if (directory === "\\") return path
354
+ if (path === directory) return ""
355
+
356
+ const separator = directory.includes("\\") ? "\\" : "/"
357
+ const prefix = directory.endsWith(separator) ? directory : directory + separator
358
+ if (!path.startsWith(prefix)) return path
359
+ return path.slice(directory.length)
360
+ }
361
+
362
+ function getDirectory(path: string | undefined) {
363
+ const data = useData()
364
+ return relativizeProjectPath(_getDirectory(path), data.directory)
365
+ }
366
+
367
+ import type { IconProps } from "@neurocode-ai/ui/icon"
368
+ import { normalize, resolveFileDiff } from "./session-diff"
369
+
370
+ export type ToolInfo = {
371
+ icon: IconProps["name"]
372
+ title: string
373
+ subtitle?: string
374
+ }
375
+
376
+ function agentTitle(i18n: UiI18n, type?: string) {
377
+ if (!type) return i18n.t("ui.tool.agent.default")
378
+ return i18n.t("ui.tool.agent", { type })
379
+ }
380
+
381
+ const agentTones: Record<string, string> = {
382
+ ask: "var(--icon-agent-ask-base)",
383
+ build: "var(--icon-agent-build-base)",
384
+ docs: "var(--icon-agent-docs-base)",
385
+ plan: "var(--icon-agent-plan-base)",
386
+ }
387
+
388
+ const v2AgentTones: Record<string, string> = {
389
+ build: "var(--v2-agent-build-solid)",
390
+ explore: "var(--v2-agent-explore-solid)",
391
+ plan: "var(--v2-agent-plan-solid)",
392
+ review: "var(--v2-agent-review-solid)",
393
+ writer: "var(--v2-agent-writer-solid)",
394
+ }
395
+
396
+ const agentThemeColors: Record<string, string> = {
397
+ primary: "var(--text-interactive-base)",
398
+ secondary: "var(--text-base)",
399
+ accent: "var(--icon-info-base)",
400
+ success: "var(--icon-success-base)",
401
+ warning: "var(--icon-warning-base)",
402
+ error: "var(--icon-critical-base)",
403
+ info: "var(--icon-info-base)",
404
+ }
405
+
406
+ const v2AgentThemeColors: Record<string, string> = {
407
+ primary: "var(--v2-text-text-accent)",
408
+ secondary: "var(--v2-text-text-muted)",
409
+ accent: "var(--v2-icon-icon-accent)",
410
+ success: "var(--v2-state-fg-success)",
411
+ warning: "var(--v2-state-fg-warning)",
412
+ error: "var(--v2-state-fg-danger)",
413
+ info: "var(--v2-state-fg-info)",
414
+ }
415
+
416
+ const agentPalette = [
417
+ "var(--icon-agent-ask-base)",
418
+ "var(--icon-agent-build-base)",
419
+ "var(--icon-agent-docs-base)",
420
+ "var(--icon-agent-plan-base)",
421
+ "var(--syntax-info)",
422
+ "var(--syntax-success)",
423
+ "var(--syntax-warning)",
424
+ "var(--syntax-property)",
425
+ "var(--syntax-constant)",
426
+ "var(--text-diff-add-base)",
427
+ "var(--text-diff-delete-base)",
428
+ "var(--icon-warning-base)",
429
+ ]
430
+
431
+ function tone(name: string) {
432
+ let hash = 0
433
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
434
+ return agentPalette[hash % agentPalette.length]
435
+ }
436
+
437
+ function taskAgent(
438
+ raw: unknown,
439
+ list?: readonly { name: string; color?: string }[],
440
+ ): { name?: string; color?: string; v2Color?: string } {
441
+ if (typeof raw !== "string" || !raw) return {}
442
+ const key = raw.toLowerCase()
443
+ const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
444
+ const v2Tone = item?.color ? undefined : v2AgentTones[key]
445
+ const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
446
+ const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone ?? color
447
+ return {
448
+ name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
449
+ color,
450
+ v2Color,
451
+ }
452
+ }
453
+
454
+ function agentColor(value: string | undefined, themeColors: Record<string, string>) {
455
+ if (!value) return
456
+ return themeColors[value] ?? value
457
+ }
458
+
459
+ function newLayout() {
460
+ return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
461
+ }
462
+
463
+ function webSearchProviderLabel(provider: unknown) {
464
+ if (provider === "parallel") return "Parallel Web Search"
465
+ if (provider === "exa") return "Exa Web Search"
466
+ return "Web Search"
467
+ }
468
+
469
+ export function getToolInfo(
470
+ tool: string,
471
+ input: any = {},
472
+ metadata: Record<string, unknown> | undefined = {},
473
+ ): ToolInfo {
474
+ const i18n = useI18n()
475
+ switch (tool) {
476
+ case "read":
477
+ return {
478
+ icon: "glasses",
479
+ title: i18n.t("ui.tool.read"),
480
+ subtitle: input.filePath ? getFilename(input.filePath) : undefined,
481
+ }
482
+ case "list":
483
+ return {
484
+ icon: "bullet-list",
485
+ title: i18n.t("ui.tool.list"),
486
+ subtitle: input.path ? getFilename(input.path) : undefined,
487
+ }
488
+ case "glob":
489
+ return {
490
+ icon: "magnifying-glass-menu",
491
+ title: i18n.t("ui.tool.glob"),
492
+ subtitle: input.pattern,
493
+ }
494
+ case "grep":
495
+ return {
496
+ icon: "magnifying-glass-menu",
497
+ title: i18n.t("ui.tool.grep"),
498
+ subtitle: input.pattern,
499
+ }
500
+ case "webfetch":
501
+ return {
502
+ icon: "window-cursor",
503
+ title: i18n.t("ui.tool.webfetch"),
504
+ subtitle: input.url,
505
+ }
506
+ case "websearch":
507
+ return {
508
+ icon: "window-cursor",
509
+ title: webSearchProviderLabel(metadata?.provider),
510
+ subtitle: input.query,
511
+ }
512
+ case "task": {
513
+ const type =
514
+ typeof input.subagent_type === "string" && input.subagent_type
515
+ ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1)
516
+ : undefined
517
+ return {
518
+ icon: "task",
519
+ title: agentTitle(i18n, type),
520
+ subtitle: input.description,
521
+ }
522
+ }
523
+ case "bash":
524
+ case "shell":
525
+ return {
526
+ icon: "console",
527
+ title: i18n.t("ui.tool.shell"),
528
+ subtitle: input.command,
529
+ }
530
+ case "edit":
531
+ return {
532
+ icon: "code-lines",
533
+ title: i18n.t("ui.messagePart.title.edit"),
534
+ subtitle: input.filePath ? getFilename(input.filePath) : undefined,
535
+ }
536
+ case "write":
537
+ return {
538
+ icon: "code-lines",
539
+ title: i18n.t("ui.messagePart.title.write"),
540
+ subtitle: input.filePath ? getFilename(input.filePath) : undefined,
541
+ }
542
+ case "patch":
543
+ case "apply_patch":
544
+ return {
545
+ icon: "code-lines",
546
+ title: i18n.t("ui.tool.patch"),
547
+ subtitle: input.files?.length
548
+ ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
549
+ : undefined,
550
+ }
551
+ case "todowrite":
552
+ return {
553
+ icon: "checklist",
554
+ title: i18n.t("ui.tool.todos"),
555
+ }
556
+ case "question":
557
+ return {
558
+ icon: "bubble-5",
559
+ title: i18n.t("ui.tool.questions"),
560
+ }
561
+ case "skill":
562
+ return {
563
+ icon: "brain",
564
+ title: input.name || i18n.t("ui.tool.skill"),
565
+ }
566
+ default:
567
+ return {
568
+ icon: "mcp",
569
+ title: tool,
570
+ }
571
+ }
572
+ }
573
+
574
+ function urls(text: string | undefined) {
575
+ if (!text) return []
576
+ const seen = new Set<string>()
577
+ return [...text.matchAll(/https?:\/\/[^\s<>"'`)\]]+/g)]
578
+ .map((item) => item[0].replace(/[),.;:!?]+$/g, ""))
579
+ .filter((item) => {
580
+ if (seen.has(item)) return false
581
+ seen.add(item)
582
+ return true
583
+ })
584
+ }
585
+
586
+ function sessionLink(id: string | undefined, path: string, href?: (id: string) => string | undefined) {
587
+ if (!id) return
588
+
589
+ const direct = href?.(id)
590
+ if (direct) return direct
591
+
592
+ const idx = path.indexOf("/session")
593
+ if (idx === -1) return
594
+ return `${path.slice(0, idx)}/session/${id}`
595
+ }
596
+
597
+ function currentSession(path: string) {
598
+ return path.match(/\/session\/([^/?#]+)/)?.[1]
599
+ }
600
+
601
+ function taskSession(
602
+ input: Record<string, any>,
603
+ path: string,
604
+ sessions: Session[] | undefined,
605
+ agents?: readonly { name: string; color?: string }[],
606
+ ) {
607
+ const parentID = currentSession(path)
608
+ if (!parentID) return
609
+ const description = typeof input.description === "string" ? input.description : ""
610
+ const agent = taskAgent(input.subagent_type, agents).name
611
+ return (sessions ?? [])
612
+ .filter((session) => session.parentID === parentID && !session.time?.archived)
613
+ .filter((session) => (description ? session.title.startsWith(description) : true))
614
+ .filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
615
+ .sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
616
+ }
617
+
618
+ const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
619
+ const HIDDEN_TOOLS = new Set(["todowrite"])
620
+
621
+ function list<T>(value: T[] | undefined | null, fallback: T[]) {
622
+ if (Array.isArray(value)) return value
623
+ return fallback
624
+ }
625
+
626
+ function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
627
+ if (a === b) return true
628
+ if (!a || !b) return false
629
+ if (a.length !== b.length) return false
630
+ return a.every((x, i) => x === b[i])
631
+ }
632
+
633
+ export type PartRef = {
634
+ messageID: string
635
+ partID: string
636
+ }
637
+
638
+ export type PartGroup =
639
+ | {
640
+ key: string
641
+ type: "part"
642
+ ref: PartRef
643
+ }
644
+ | {
645
+ key: string
646
+ type: "context"
647
+ refs: PartRef[]
648
+ }
649
+
650
+ function sameRef(a: PartRef, b: PartRef) {
651
+ return a.messageID === b.messageID && a.partID === b.partID
652
+ }
653
+
654
+ function sameGroup(a: PartGroup, b: PartGroup) {
655
+ if (a === b) return true
656
+ if (a.key !== b.key) return false
657
+ if (a.type !== b.type) return false
658
+ if (a.type === "part") {
659
+ if (b.type !== "part") return false
660
+ return sameRef(a.ref, b.ref)
661
+ }
662
+ if (b.type !== "context") return false
663
+ if (a.refs.length !== b.refs.length) return false
664
+ return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
665
+ }
666
+
667
+ export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
668
+ if (a === b) return true
669
+ if (!a || !b) return false
670
+ if (a.length !== b.length) return false
671
+ return a.every((item, i) => sameGroup(item, b[i]!))
672
+ }
673
+
674
+ export function groupParts(parts: { messageID: string; part: PartType }[]) {
675
+ const result: PartGroup[] = []
676
+ let start = -1
677
+
678
+ const flush = (end: number) => {
679
+ if (start < 0) return
680
+ const first = parts[start]
681
+ const last = parts[end]
682
+ if (!first || !last) {
683
+ start = -1
684
+ return
685
+ }
686
+ result.push({
687
+ key: `context:${first.part.id}`,
688
+ type: "context",
689
+ refs: parts.slice(start, end + 1).map((item) => ({
690
+ messageID: item.messageID,
691
+ partID: item.part.id,
692
+ })),
693
+ })
694
+ start = -1
695
+ }
696
+
697
+ parts.forEach((item, index) => {
698
+ if (isContextGroupTool(item.part)) {
699
+ if (start < 0) start = index
700
+ return
701
+ }
702
+
703
+ flush(index - 1)
704
+ result.push({
705
+ key: `part:${item.messageID}:${item.part.id}`,
706
+ type: "part",
707
+ ref: {
708
+ messageID: item.messageID,
709
+ partID: item.part.id,
710
+ },
711
+ })
712
+ })
713
+
714
+ flush(parts.length - 1)
715
+ return result
716
+ }
717
+
718
+ function index<T extends { id: string }>(items: readonly T[]) {
719
+ return new Map(items.map((item) => [item.id, item] as const))
720
+ }
721
+
722
+ export function renderable(part: PartType, showReasoningSummaries = true) {
723
+ if (part.type === "tool") {
724
+ if (HIDDEN_TOOLS.has(part.tool)) return false
725
+ if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
726
+ return true
727
+ }
728
+ if (part.type === "text") return !!part.text?.trim()
729
+ if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
730
+ return !!PART_MAPPING[part.type]
731
+ }
732
+
733
+ function toolDefaultOpen(tool: string, shell = false, edit = false) {
734
+ if (tool === "bash" || tool === "shell") return shell
735
+ if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
736
+ }
737
+
738
+ export function partDefaultOpen(part: PartType, shell = false, edit = false) {
739
+ if (part.type !== "tool") return
740
+ return toolDefaultOpen(part.tool, shell, edit)
741
+ }
742
+
743
+ export function AssistantParts(props: {
744
+ messages: AssistantMessage[]
745
+ showAssistantCopyPartID?: string | null
746
+ turnDurationMs?: number
747
+ useV2Actions?: boolean
748
+ working?: boolean
749
+ showReasoningSummaries?: boolean
750
+ shellToolDefaultOpen?: boolean
751
+ editToolDefaultOpen?: boolean
752
+ }) {
753
+ const data = useData()
754
+ const emptyParts: PartType[] = []
755
+ const emptyTools: ToolPart[] = []
756
+ const msgs = createMemo(() => index(props.messages))
757
+ const part = createMemo(
758
+ () =>
759
+ new Map(
760
+ props.messages.map((message) => [message.id, index(list(data.store.part?.[message.id], emptyParts))] as const),
761
+ ),
762
+ )
763
+
764
+ const grouped = createMemo(
765
+ () =>
766
+ groupParts(
767
+ props.messages.flatMap((message) =>
768
+ list(data.store.part?.[message.id], emptyParts)
769
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
770
+ .map((part) => ({
771
+ messageID: message.id,
772
+ part,
773
+ })),
774
+ ),
775
+ ),
776
+ [] as PartGroup[],
777
+ { equals: sameGroups },
778
+ )
779
+
780
+ const last = createMemo(() => grouped().at(-1)?.key)
781
+
782
+ return (
783
+ <Index each={grouped()}>
784
+ {(entryAccessor) => {
785
+ const entryType = createMemo(() => entryAccessor().type)
786
+
787
+ return (
788
+ <Switch>
789
+ <Match when={entryType() === "context"}>
790
+ {(() => {
791
+ const parts = createMemo(
792
+ () => {
793
+ const entry = entryAccessor()
794
+ if (entry.type !== "context") return emptyTools
795
+ return entry.refs
796
+ .map((ref) => part().get(ref.messageID)?.get(ref.partID))
797
+ .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
798
+ },
799
+ emptyTools,
800
+ { equals: same },
801
+ )
802
+ const busy = createMemo(() => props.working && last() === entryAccessor().key)
803
+
804
+ return (
805
+ <Show when={parts().length > 0}>
806
+ <ContextToolGroup parts={parts()} busy={busy()} />
807
+ </Show>
808
+ )
809
+ })()}
810
+ </Match>
811
+ <Match when={entryType() === "part"}>
812
+ {(() => {
813
+ const message = createMemo(() => {
814
+ const entry = entryAccessor()
815
+ if (entry.type !== "part") return
816
+ return msgs().get(entry.ref.messageID)
817
+ })
818
+ const item = createMemo(() => {
819
+ const entry = entryAccessor()
820
+ if (entry.type !== "part") return
821
+ return part().get(entry.ref.messageID)?.get(entry.ref.partID)
822
+ })
823
+
824
+ return (
825
+ <Show when={message()}>
826
+ <Show when={item()}>
827
+ <Part
828
+ part={item()!}
829
+ message={message()!}
830
+ showAssistantCopyPartID={props.showAssistantCopyPartID}
831
+ turnDurationMs={props.turnDurationMs}
832
+ useV2Actions={props.useV2Actions}
833
+ defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
834
+ />
835
+ </Show>
836
+ </Show>
837
+ )
838
+ })()}
839
+ </Match>
840
+ </Switch>
841
+ )
842
+ }}
843
+ </Index>
844
+ )
845
+ }
846
+
847
+ function isContextGroupTool(part: PartType): part is ToolPart {
848
+ return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
849
+ }
850
+
851
+ function contextToolDetail(part: ToolPart): string | undefined {
852
+ const info = getToolInfo(
853
+ part.tool,
854
+ part.state.input ?? {},
855
+ "metadata" in part.state ? part.state.metadata : undefined,
856
+ )
857
+ if (info.subtitle) return info.subtitle
858
+ if (part.state.status === "error") return part.state.error
859
+ if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
860
+ return part.state.title
861
+ const description = part.state.input?.description
862
+ if (typeof description === "string") return description
863
+ return undefined
864
+ }
865
+
866
+ function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
867
+ const input = (part.state.input ?? {}) as Record<string, unknown>
868
+ const path = typeof input.path === "string" ? input.path : "/"
869
+ const filePath = typeof input.filePath === "string" ? input.filePath : undefined
870
+ const pattern = typeof input.pattern === "string" ? input.pattern : undefined
871
+ const include = typeof input.include === "string" ? input.include : undefined
872
+ const offset = typeof input.offset === "number" ? input.offset : undefined
873
+ const limit = typeof input.limit === "number" ? input.limit : undefined
874
+
875
+ switch (part.tool) {
876
+ case "read": {
877
+ const args: string[] = []
878
+ if (offset !== undefined) args.push("offset=" + offset)
879
+ if (limit !== undefined) args.push("limit=" + limit)
880
+ return {
881
+ title: i18n.t("ui.tool.read"),
882
+ subtitle: filePath ? getFilename(filePath) : "",
883
+ args,
884
+ }
885
+ }
886
+ case "list":
887
+ return {
888
+ title: i18n.t("ui.tool.list"),
889
+ subtitle: getDirectory(path),
890
+ }
891
+ case "glob":
892
+ return {
893
+ title: i18n.t("ui.tool.glob"),
894
+ subtitle: getDirectory(path),
895
+ args: pattern ? ["pattern=" + pattern] : [],
896
+ }
897
+ case "grep": {
898
+ const args: string[] = []
899
+ if (pattern) args.push("pattern=" + pattern)
900
+ if (include) args.push("include=" + include)
901
+ return {
902
+ title: i18n.t("ui.tool.grep"),
903
+ subtitle: getDirectory(path),
904
+ args,
905
+ }
906
+ }
907
+ default: {
908
+ const info = getToolInfo(part.tool, input, "metadata" in part.state ? part.state.metadata : undefined)
909
+ return {
910
+ title: info.title,
911
+ subtitle: info.subtitle || contextToolDetail(part),
912
+ args: [],
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ function contextToolSummary(parts: ToolPart[]) {
919
+ const read = parts.filter((part) => part.tool === "read").length
920
+ const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
921
+ const list = parts.filter((part) => part.tool === "list").length
922
+ return { read, search, list }
923
+ }
924
+
925
+ function ExaOutput(props: { output?: string }) {
926
+ const links = createMemo(() => urls(props.output))
927
+
928
+ return (
929
+ <Show when={links().length > 0}>
930
+ <div data-component="exa-tool-output">
931
+ <div data-slot="exa-tool-links">
932
+ <For each={links()}>
933
+ {(url) => (
934
+ <a
935
+ data-slot="exa-tool-link"
936
+ href={url}
937
+ target="_blank"
938
+ rel="noopener noreferrer"
939
+ onClick={(event) => event.stopPropagation()}
940
+ >
941
+ {url}
942
+ </a>
943
+ )}
944
+ </For>
945
+ </div>
946
+ </div>
947
+ </Show>
948
+ )
949
+ }
950
+
951
+ export function registerPartComponent(type: string, component: PartComponent) {
952
+ PART_MAPPING[type] = component
953
+ }
954
+
955
+ export function Message(props: MessageProps) {
956
+ return (
957
+ <Switch>
958
+ <Match when={props.message.role === "user" && props.message}>
959
+ {(userMessage) => (
960
+ <UserMessageDisplay
961
+ message={userMessage() as UserMessage}
962
+ parts={props.parts}
963
+ actions={props.actions}
964
+ useV2Actions={props.useV2Actions}
965
+ comments={props.comments}
966
+ />
967
+ )}
968
+ </Match>
969
+ <Match when={props.message.role === "assistant" && props.message}>
970
+ {(assistantMessage) => (
971
+ <AssistantMessageDisplay
972
+ message={assistantMessage() as AssistantMessage}
973
+ parts={props.parts}
974
+ showAssistantCopyPartID={props.showAssistantCopyPartID}
975
+ showReasoningSummaries={props.showReasoningSummaries}
976
+ useV2Actions={props.useV2Actions}
977
+ />
978
+ )}
979
+ </Match>
980
+ </Switch>
981
+ )
982
+ }
983
+
984
+ export function AssistantMessageDisplay(props: {
985
+ message: AssistantMessage
986
+ parts: PartType[]
987
+ showAssistantCopyPartID?: string | null
988
+ showReasoningSummaries?: boolean
989
+ useV2Actions?: boolean
990
+ }) {
991
+ const emptyTools: ToolPart[] = []
992
+ const part = createMemo(() => index(props.parts))
993
+ const grouped = createMemo(
994
+ () =>
995
+ groupParts(
996
+ props.parts
997
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
998
+ .map((part) => ({
999
+ messageID: props.message.id,
1000
+ part,
1001
+ })),
1002
+ ),
1003
+ [] as PartGroup[],
1004
+ { equals: sameGroups },
1005
+ )
1006
+
1007
+ return (
1008
+ <Index each={grouped()}>
1009
+ {(entryAccessor) => {
1010
+ const entryType = createMemo(() => entryAccessor().type)
1011
+
1012
+ return (
1013
+ <Switch>
1014
+ <Match when={entryType() === "context"}>
1015
+ {(() => {
1016
+ const parts = createMemo(
1017
+ () => {
1018
+ const entry = entryAccessor()
1019
+ if (entry.type !== "context") return emptyTools
1020
+ return entry.refs
1021
+ .map((ref) => part().get(ref.partID))
1022
+ .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
1023
+ },
1024
+ emptyTools,
1025
+ { equals: same },
1026
+ )
1027
+
1028
+ return (
1029
+ <Show when={parts().length > 0}>
1030
+ <ContextToolGroup parts={parts()} />
1031
+ </Show>
1032
+ )
1033
+ })()}
1034
+ </Match>
1035
+ <Match when={entryType() === "part"}>
1036
+ {(() => {
1037
+ const item = createMemo(() => {
1038
+ const entry = entryAccessor()
1039
+ if (entry.type !== "part") return
1040
+ return part().get(entry.ref.partID)
1041
+ })
1042
+
1043
+ return (
1044
+ <Show when={item()}>
1045
+ <Part
1046
+ part={item()!}
1047
+ message={props.message}
1048
+ showAssistantCopyPartID={props.showAssistantCopyPartID}
1049
+ useV2Actions={props.useV2Actions}
1050
+ />
1051
+ </Show>
1052
+ )
1053
+ })()}
1054
+ </Match>
1055
+ </Switch>
1056
+ )
1057
+ }}
1058
+ </Index>
1059
+ )
1060
+ }
1061
+
1062
+ export function ContextToolGroup(props: {
1063
+ parts: ToolPart[]
1064
+ busy?: boolean
1065
+ open?: boolean
1066
+ onOpenChange?: (open: boolean) => void
1067
+ onSizeChange?: () => void
1068
+ }) {
1069
+ const i18n = useI18n()
1070
+ const [localOpen, setLocalOpen] = createSignal(false)
1071
+ const open = () => props.open ?? localOpen()
1072
+ const pending = createMemo(
1073
+ () =>
1074
+ !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
1075
+ )
1076
+ const summary = createMemo(() => contextToolSummary(props.parts))
1077
+ const handleOpenChange = (value: boolean) => {
1078
+ if (props.open === undefined) setLocalOpen(value)
1079
+ props.onOpenChange?.(value)
1080
+ props.onSizeChange?.()
1081
+ }
1082
+
1083
+ return (
1084
+ <Collapsible
1085
+ open={open()}
1086
+ onOpenChange={handleOpenChange}
1087
+ variant="ghost"
1088
+ class="tool-collapsible"
1089
+ data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
1090
+ >
1091
+ <Collapsible.Trigger>
1092
+ <div data-component="context-tool-group-trigger">
1093
+ <span
1094
+ data-slot="context-tool-group-title"
1095
+ class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
1096
+ >
1097
+ <span data-slot="context-tool-group-label" class="shrink-0">
1098
+ <ToolStatusTitle
1099
+ active={pending()}
1100
+ activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
1101
+ doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
1102
+ split={false}
1103
+ />
1104
+ </span>
1105
+ <span
1106
+ data-slot="context-tool-group-summary"
1107
+ class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
1108
+ >
1109
+ <AnimatedCountList
1110
+ items={[
1111
+ {
1112
+ key: "read",
1113
+ count: summary().read,
1114
+ one: i18n.t("ui.messagePart.context.read.one"),
1115
+ other: i18n.t("ui.messagePart.context.read.other"),
1116
+ },
1117
+ {
1118
+ key: "search",
1119
+ count: summary().search,
1120
+ one: i18n.t("ui.messagePart.context.search.one"),
1121
+ other: i18n.t("ui.messagePart.context.search.other"),
1122
+ },
1123
+ {
1124
+ key: "list",
1125
+ count: summary().list,
1126
+ one: i18n.t("ui.messagePart.context.list.one"),
1127
+ other: i18n.t("ui.messagePart.context.list.other"),
1128
+ },
1129
+ ]}
1130
+ fallback=""
1131
+ />
1132
+ </span>
1133
+ </span>
1134
+ <Collapsible.Arrow />
1135
+ </div>
1136
+ </Collapsible.Trigger>
1137
+ <Collapsible.Content>
1138
+ <div data-component="context-tool-group-list">
1139
+ <Index each={props.parts}>
1140
+ {(partAccessor) => {
1141
+ const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
1142
+ const running = createMemo(
1143
+ () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
1144
+ )
1145
+ return (
1146
+ <div data-slot="context-tool-group-item">
1147
+ <div data-component="tool-trigger">
1148
+ <div data-slot="basic-tool-tool-trigger-content">
1149
+ <div data-slot="basic-tool-tool-info">
1150
+ <div data-slot="basic-tool-tool-info-structured">
1151
+ <div data-slot="basic-tool-tool-info-main">
1152
+ <span data-slot="basic-tool-tool-title">
1153
+ <TextShimmer text={trigger().title} active={running()} />
1154
+ </span>
1155
+ <Show when={!running() && trigger().subtitle}>
1156
+ <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
1157
+ </Show>
1158
+ <Show when={!running() && trigger().args?.length}>
1159
+ <For each={trigger().args}>
1160
+ {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
1161
+ </For>
1162
+ </Show>
1163
+ </div>
1164
+ </div>
1165
+ </div>
1166
+ </div>
1167
+ </div>
1168
+ </div>
1169
+ )
1170
+ }}
1171
+ </Index>
1172
+ </div>
1173
+ </Collapsible.Content>
1174
+ </Collapsible>
1175
+ )
1176
+ }
1177
+
1178
+ function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) {
1179
+ const i18n = useI18n()
1180
+ const [state, setState] = createStore({ expanded: false })
1181
+ const comments = createMemo(() => (props.bounded && !state.expanded ? props.comments.slice(0, 5) : props.comments))
1182
+
1183
+ return (
1184
+ <div data-slot="user-message-comments" data-bounded={props.bounded ? "true" : undefined}>
1185
+ <For each={comments()}>
1186
+ {(comment) => (
1187
+ <CommentCardV2
1188
+ comment={comment.comment}
1189
+ path={comment.path}
1190
+ selection={comment.selection}
1191
+ title={comment.comment}
1192
+ tooltip
1193
+ wide
1194
+ />
1195
+ )}
1196
+ </For>
1197
+ <Show when={props.bounded && props.comments.length > 5 && !state.expanded}>
1198
+ <ButtonV2 size="small" variant="ghost-muted" onClick={() => setState("expanded", true)}>
1199
+ {i18n.t("ui.common.showMore")}
1200
+ </ButtonV2>
1201
+ </Show>
1202
+ </div>
1203
+ )
1204
+ }
1205
+
1206
+ export function UserMessageDisplay(props: {
1207
+ message: UserMessage
1208
+ parts: PartType[]
1209
+ actions?: UserActions
1210
+ useV2Actions?: boolean
1211
+ comments?: UserMessageComment[]
1212
+ }) {
1213
+ const data = useData()
1214
+ const dialog = useDialog()
1215
+ const i18n = useI18n()
1216
+ const [state, setState] = createStore({
1217
+ copied: false,
1218
+ busy: false,
1219
+ })
1220
+ const copied = () => state.copied
1221
+ const busy = () => state.busy
1222
+
1223
+ const textPart = createMemo(
1224
+ () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
1225
+ )
1226
+
1227
+ const text = createMemo(() => textPart()?.text || "")
1228
+
1229
+ const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
1230
+
1231
+ const attachments = createMemo(() => files().filter(attached))
1232
+
1233
+ const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
1234
+
1235
+ const inlineFiles = createMemo(() => files().filter(inline))
1236
+
1237
+ const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
1238
+
1239
+ const model = createMemo(() => {
1240
+ const providerID = props.message.model?.providerID
1241
+ const modelID = props.message.model?.modelID
1242
+ if (!providerID || !modelID) return ""
1243
+ const match = data.store.provider?.all?.get(providerID)
1244
+ return match?.models?.[modelID]?.name ?? modelID
1245
+ })
1246
+ const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
1247
+
1248
+ const stamp = createMemo(() => {
1249
+ const created = props.message.time?.created
1250
+ if (typeof created !== "number") return ""
1251
+ return timefmt().format(created)
1252
+ })
1253
+
1254
+ const metaHead = createMemo(() => {
1255
+ const agent = props.message.agent
1256
+ const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
1257
+ return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
1258
+ })
1259
+
1260
+ const metaTail = stamp
1261
+
1262
+ const openImagePreview = (url: string, alt?: string) => {
1263
+ dialog.show(() => <ImagePreview src={url} alt={alt} />)
1264
+ }
1265
+
1266
+ const handleCopy = async () => {
1267
+ const content = text()
1268
+ if (!content) return
1269
+ if (await writeClipboard(content)) {
1270
+ setState("copied", true)
1271
+ setTimeout(() => setState("copied", false), 2000)
1272
+ }
1273
+ }
1274
+
1275
+ const revert = () => {
1276
+ const act = props.actions?.revert
1277
+ if (!act || busy()) return
1278
+ setState("busy", true)
1279
+ void Promise.resolve()
1280
+ .then(() =>
1281
+ act({
1282
+ sessionID: props.message.sessionID,
1283
+ messageID: props.message.id,
1284
+ }),
1285
+ )
1286
+ .finally(() => setState("busy", false))
1287
+ }
1288
+
1289
+ const renderAttachments = () => (
1290
+ <Show when={attachments().length > 0}>
1291
+ <div data-slot="user-message-attachments">
1292
+ <For each={attachments()}>
1293
+ {(file) => {
1294
+ const type = kind(file)
1295
+ const name = file.filename ?? i18n.t("ui.message.attachment.alt")
1296
+
1297
+ return (
1298
+ <Show
1299
+ when={newLayout() && type === "file"}
1300
+ fallback={
1301
+ <div
1302
+ data-slot="user-message-attachment"
1303
+ data-type={type}
1304
+ data-clickable={type === "image" ? "true" : undefined}
1305
+ title={type === "file" ? name : undefined}
1306
+ onClick={() => {
1307
+ if (type === "image") openImagePreview(file.url, name)
1308
+ }}
1309
+ >
1310
+ <Show
1311
+ when={type === "image"}
1312
+ fallback={
1313
+ <div data-slot="user-message-attachment-file">
1314
+ <FileIcon node={{ path: name, type: "file" }} />
1315
+ <span data-slot="user-message-attachment-name">{name}</span>
1316
+ </div>
1317
+ }
1318
+ >
1319
+ <img data-slot="user-message-attachment-image" src={file.url} alt={name} />
1320
+ </Show>
1321
+ </div>
1322
+ }
1323
+ >
1324
+ <AttachmentCardV2
1325
+ title={getFilename(name)}
1326
+ hover={name}
1327
+ clickable={!!props.actions?.openAttachment}
1328
+ onClick={() => props.actions?.openAttachment?.(file)}
1329
+ >
1330
+ {typeLabel(name, file.mime)}
1331
+ </AttachmentCardV2>
1332
+ </Show>
1333
+ )
1334
+ }}
1335
+ </For>
1336
+ </div>
1337
+ </Show>
1338
+ )
1339
+
1340
+ return (
1341
+ <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
1342
+ <Show when={!props.useV2Actions}>{renderAttachments()}</Show>
1343
+ <Show
1344
+ when={text()}
1345
+ fallback={
1346
+ <Show when={messageComments().length > 0}>
1347
+ <UserMessageComments comments={messageComments()} bounded={false} />
1348
+ </Show>
1349
+ }
1350
+ >
1351
+ <div data-slot="user-message-body">
1352
+ <div data-slot="user-message-text" data-comments={messageComments().length > 0 ? "true" : undefined}>
1353
+ <HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
1354
+ <Show when={messageComments().length > 0}>
1355
+ <UserMessageComments comments={messageComments()} bounded />
1356
+ </Show>
1357
+ </div>
1358
+ </div>
1359
+ </Show>
1360
+ <Show when={props.useV2Actions}>{renderAttachments()}</Show>
1361
+ <Show when={text() || (props.useV2Actions && messageComments().length > 0)}>
1362
+ <div data-slot="user-message-copy-wrapper">
1363
+ <Show when={metaHead() || metaTail()}>
1364
+ <span data-slot="user-message-meta-wrap">
1365
+ <Show when={metaHead()}>
1366
+ <span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
1367
+ {metaHead()}
1368
+ </span>
1369
+ </Show>
1370
+ <Show when={metaHead() && metaTail()}>
1371
+ <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
1372
+ {"\u00A0\u00B7\u00A0"}
1373
+ </span>
1374
+ </Show>
1375
+ <Show when={metaTail()}>
1376
+ <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
1377
+ {metaTail()}
1378
+ </span>
1379
+ </Show>
1380
+ </span>
1381
+ </Show>
1382
+ <Show when={props.actions?.revert}>
1383
+ <MessageActionButton
1384
+ icon="reset"
1385
+ label={i18n.t("ui.message.revertMessage")}
1386
+ useV2={props.useV2Actions}
1387
+ disabled={!!busy()}
1388
+ onMouseDown={(event) => event.preventDefault()}
1389
+ onClick={(event) => {
1390
+ event.stopPropagation()
1391
+ revert()
1392
+ }}
1393
+ aria-label={i18n.t("ui.message.revertMessage")}
1394
+ />
1395
+ </Show>
1396
+ <Show when={text()}>
1397
+ <MessageActionButton
1398
+ icon={copied() ? "check" : "copy"}
1399
+ label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
1400
+ useV2={props.useV2Actions}
1401
+ onMouseDown={(event) => event.preventDefault()}
1402
+ onClick={(event) => {
1403
+ event.stopPropagation()
1404
+ void handleCopy()
1405
+ }}
1406
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
1407
+ />
1408
+ </Show>
1409
+ </div>
1410
+ </Show>
1411
+ </div>
1412
+ )
1413
+ }
1414
+
1415
+ type HighlightSegment = { text: string; type?: "file" | "agent" }
1416
+
1417
+ function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) {
1418
+ const segments = createMemo(() => {
1419
+ const text = props.text
1420
+
1421
+ const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [
1422
+ ...props.references
1423
+ .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined)
1424
+ .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })),
1425
+ ...props.agents
1426
+ .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined)
1427
+ .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })),
1428
+ ].sort((a, b) => a.start - b.start)
1429
+
1430
+ const result: HighlightSegment[] = []
1431
+ let lastIndex = 0
1432
+
1433
+ for (const ref of allRefs) {
1434
+ if (ref.start < lastIndex) continue
1435
+
1436
+ if (ref.start > lastIndex) {
1437
+ result.push({ text: text.slice(lastIndex, ref.start) })
1438
+ }
1439
+
1440
+ result.push({ text: text.slice(ref.start, ref.end), type: ref.type })
1441
+ lastIndex = ref.end
1442
+ }
1443
+
1444
+ if (lastIndex < text.length) {
1445
+ result.push({ text: text.slice(lastIndex) })
1446
+ }
1447
+
1448
+ return result
1449
+ })
1450
+
1451
+ return <For each={segments()}>{(segment) => <span data-highlight={segment.type}>{segment.text}</span>}</For>
1452
+ }
1453
+
1454
+ export function Part(props: MessagePartProps) {
1455
+ const component = createMemo(() => PART_MAPPING[props.part.type])
1456
+ return (
1457
+ <Show when={component()}>
1458
+ <Dynamic
1459
+ component={component()}
1460
+ part={props.part}
1461
+ message={props.message}
1462
+ hideDetails={props.hideDetails}
1463
+ defaultOpen={props.defaultOpen}
1464
+ toolOpen={props.toolOpen}
1465
+ onToolOpenChange={props.onToolOpenChange}
1466
+ deferToolContent={props.deferToolContent}
1467
+ virtualizeDiff={props.virtualizeDiff}
1468
+ onContentRendered={props.onContentRendered}
1469
+ showAssistantCopyPartID={props.showAssistantCopyPartID}
1470
+ turnDurationMs={props.turnDurationMs}
1471
+ useV2Actions={props.useV2Actions}
1472
+ />
1473
+ </Show>
1474
+ )
1475
+ }
1476
+
1477
+ export interface ToolProps {
1478
+ input: Record<string, any>
1479
+ metadata: Record<string, any>
1480
+ tool: string
1481
+ sessionID?: string
1482
+ output?: string
1483
+ status?: string
1484
+ hideDetails?: boolean
1485
+ defaultOpen?: boolean
1486
+ open?: boolean
1487
+ onOpenChange?: (open: boolean) => void
1488
+ deferContent?: boolean
1489
+ virtualizeDiff?: boolean
1490
+ onContentRendered?: () => void
1491
+ forceOpen?: boolean
1492
+ locked?: boolean
1493
+ }
1494
+
1495
+ export type ToolComponent = Component<ToolProps>
1496
+
1497
+ const state: Record<
1498
+ string,
1499
+ {
1500
+ name: string
1501
+ render?: ToolComponent
1502
+ }
1503
+ > = {}
1504
+
1505
+ export function registerTool(input: { name: string; render?: ToolComponent }) {
1506
+ state[input.name] = input
1507
+ return input
1508
+ }
1509
+
1510
+ export function getTool(name: string) {
1511
+ return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
1512
+ }
1513
+
1514
+ export const ToolRegistry = {
1515
+ register: registerTool,
1516
+ render: getTool,
1517
+ }
1518
+
1519
+ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
1520
+ const value = createMemo(() => props.path || "tool-file")
1521
+
1522
+ return (
1523
+ <Accordion
1524
+ multiple
1525
+ data-scope="apply-patch"
1526
+ style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
1527
+ defaultValue={[value()]}
1528
+ >
1529
+ <Accordion.Item value={value()}>
1530
+ <StickyAccordionHeader>
1531
+ <Accordion.Trigger>
1532
+ <div data-slot="apply-patch-trigger-content">
1533
+ <div data-slot="apply-patch-file-info">
1534
+ <FileIcon node={{ path: props.path, type: "file" }} />
1535
+ <div data-slot="apply-patch-file-name-container">
1536
+ <Show when={props.path.includes("/")}>
1537
+ <span data-slot="apply-patch-directory">{`\u202A${getDirectory(props.path)}\u202C`}</span>
1538
+ </Show>
1539
+ <span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
1540
+ </div>
1541
+ </div>
1542
+ <div data-slot="apply-patch-trigger-actions">
1543
+ {props.actions}
1544
+ <Icon name="chevron-grabber-vertical" size="small" />
1545
+ </div>
1546
+ </div>
1547
+ </Accordion.Trigger>
1548
+ </StickyAccordionHeader>
1549
+ <Accordion.Content>{props.children}</Accordion.Content>
1550
+ </Accordion.Item>
1551
+ </Accordion>
1552
+ )
1553
+ }
1554
+
1555
+ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
1556
+ const data = useData()
1557
+ const i18n = useI18n()
1558
+ const part = () => props.part as ToolPart
1559
+ if (part().tool === "todowrite") return null
1560
+
1561
+ const hideQuestion = createMemo(
1562
+ () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
1563
+ )
1564
+
1565
+ const emptyInput: Record<string, any> = {}
1566
+ const emptyMetadata: Record<string, any> = {}
1567
+
1568
+ const input = () => part().state?.input ?? emptyInput
1569
+ // @ts-expect-error
1570
+ const partMetadata = () => part().state?.metadata ?? emptyMetadata
1571
+ const taskId = createMemo(() => {
1572
+ if (part().tool !== "task") return
1573
+ const value = partMetadata().sessionId
1574
+ if (typeof value === "string" && value) return value
1575
+ })
1576
+ const taskHref = createMemo(() => {
1577
+ if (part().tool !== "task") return
1578
+ return sessionLink(taskId(), useLocation().pathname, data.sessionHref)
1579
+ })
1580
+ const taskSubtitle = createMemo(() => {
1581
+ if (part().tool !== "task") return undefined
1582
+ const value = input().description
1583
+ if (typeof value === "string" && value) return value
1584
+ return taskId()
1585
+ })
1586
+
1587
+ const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
1588
+ const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
1589
+ const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
1590
+
1591
+ return (
1592
+ <Show when={!hideQuestion()}>
1593
+ <div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
1594
+ <Switch>
1595
+ <Match when={part().state.status === "error" && (part().state as any).error}>
1596
+ {(error) => {
1597
+ const cleaned = error().replace("Error: ", "")
1598
+ if (part().tool === "question" && cleaned.includes("dismissed this question")) {
1599
+ return (
1600
+ <div style="width: 100%; display: flex; justify-content: flex-end;">
1601
+ <span class="text-13-regular text-text-weak cursor-default">
1602
+ {i18n.t("ui.messagePart.questions.dismissed")}
1603
+ </span>
1604
+ </div>
1605
+ )
1606
+ }
1607
+ return (
1608
+ <ToolErrorCard
1609
+ tool={part().tool}
1610
+ error={error()}
1611
+ title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
1612
+ defaultOpen={props.defaultOpen}
1613
+ open={controlledOpen()}
1614
+ onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
1615
+ subtitle={taskSubtitle()}
1616
+ href={taskHref()}
1617
+ />
1618
+ )
1619
+ }}
1620
+ </Match>
1621
+ <Match when={true}>
1622
+ <Dynamic
1623
+ component={render()}
1624
+ input={input()}
1625
+ tool={part().tool}
1626
+ sessionID={part().sessionID}
1627
+ metadata={partMetadata()}
1628
+ // @ts-expect-error
1629
+ output={part().state.output}
1630
+ status={part().state.status}
1631
+ hideDetails={props.hideDetails}
1632
+ defaultOpen={props.defaultOpen}
1633
+ open={controlledOpen()}
1634
+ onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
1635
+ deferContent={props.deferToolContent}
1636
+ virtualizeDiff={props.virtualizeDiff}
1637
+ onContentRendered={props.onContentRendered}
1638
+ />
1639
+ </Match>
1640
+ </Switch>
1641
+ </div>
1642
+ </Show>
1643
+ )
1644
+ }
1645
+
1646
+ export function MessageDivider(props: { label: string }) {
1647
+ return (
1648
+ <div data-component="compaction-part">
1649
+ <div data-slot="compaction-part-divider">
1650
+ <span data-slot="compaction-part-line" />
1651
+ <span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
1652
+ {props.label}
1653
+ </span>
1654
+ <span data-slot="compaction-part-line" />
1655
+ </div>
1656
+ </div>
1657
+ )
1658
+ }
1659
+
1660
+ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
1661
+ const i18n = useI18n()
1662
+ return <MessageDivider label={i18n.t("ui.messagePart.compaction")} />
1663
+ }
1664
+
1665
+ PART_MAPPING["text"] = function TextPartDisplay(props) {
1666
+ const data = useData()
1667
+ const i18n = useI18n()
1668
+ const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
1669
+ const part = () => props.part as TextPart
1670
+ const interrupted = createMemo(
1671
+ () =>
1672
+ props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
1673
+ )
1674
+
1675
+ const model = createMemo(() => {
1676
+ if (props.message.role !== "assistant") return ""
1677
+ const message = props.message as AssistantMessage
1678
+ const match = data.store.provider?.all?.get(message.providerID)
1679
+ return match?.models?.[message.modelID]?.name ?? message.modelID
1680
+ })
1681
+
1682
+ const duration = createMemo(() => {
1683
+ if (props.message.role !== "assistant") return ""
1684
+ const message = props.message as AssistantMessage
1685
+ const completed = message.time.completed
1686
+ const ms =
1687
+ typeof props.turnDurationMs === "number"
1688
+ ? props.turnDurationMs
1689
+ : typeof completed === "number"
1690
+ ? completed - message.time.created
1691
+ : -1
1692
+ if (!(ms >= 0)) return ""
1693
+ const total = Math.round(ms / 1000)
1694
+ if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
1695
+ const minutes = Math.floor(total / 60)
1696
+ const seconds = total % 60
1697
+ return i18n.t("ui.message.duration.minutesSeconds", {
1698
+ minutes: numfmt().format(minutes),
1699
+ seconds: numfmt().format(seconds),
1700
+ })
1701
+ })
1702
+
1703
+ const meta = createMemo(() => {
1704
+ if (props.message.role !== "assistant") return ""
1705
+ const agent = (props.message as AssistantMessage).agent
1706
+ const items = [
1707
+ agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
1708
+ model(),
1709
+ duration(),
1710
+ interrupted() ? i18n.t("ui.message.interrupted") : "",
1711
+ ]
1712
+ return items.filter((x) => !!x).join(" \u00B7 ")
1713
+ })
1714
+
1715
+ const streaming = createMemo(
1716
+ () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
1717
+ )
1718
+ const text = () => readPartText(data.store.part_text_accum_delta, part())
1719
+ const isLastTextPart = createMemo(() => {
1720
+ const last = (data.store.part?.[props.message.id] ?? [])
1721
+ .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
1722
+ .at(-1)
1723
+ return last?.id === part().id
1724
+ })
1725
+ const showCopy = createMemo(() => {
1726
+ if (props.message.role !== "assistant") return isLastTextPart()
1727
+ if (props.showAssistantCopyPartID === null) return false
1728
+ if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
1729
+ return isLastTextPart()
1730
+ })
1731
+ const [copied, setCopied] = createSignal(false)
1732
+
1733
+ const handleCopy = async () => {
1734
+ const content = text()
1735
+ if (!content) return
1736
+ if (await writeClipboard(content)) {
1737
+ setCopied(true)
1738
+ setTimeout(() => setCopied(false), 2000)
1739
+ }
1740
+ }
1741
+
1742
+ return (
1743
+ <Show when={text()}>
1744
+ <div data-component="text-part" data-timeline-part-id={part().id}>
1745
+ <div data-slot="text-part-body">
1746
+ <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
1747
+ <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
1748
+ </Show>
1749
+ </div>
1750
+ <Show when={showCopy()}>
1751
+ <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
1752
+ <MessageActionButton
1753
+ icon={copied() ? "check" : "copy"}
1754
+ label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
1755
+ useV2={props.useV2Actions}
1756
+ onMouseDown={(event) => event.preventDefault()}
1757
+ onClick={handleCopy}
1758
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
1759
+ />
1760
+ <Show when={meta()}>
1761
+ <span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default">
1762
+ {meta()}
1763
+ </span>
1764
+ </Show>
1765
+ </div>
1766
+ </Show>
1767
+ </div>
1768
+ </Show>
1769
+ )
1770
+ }
1771
+
1772
+ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
1773
+ const data = useData()
1774
+ const part = () => props.part as ReasoningPart
1775
+ const streaming = createMemo(
1776
+ () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
1777
+ )
1778
+ const text = () => readPartText(data.store.part_text_accum_delta, part())
1779
+
1780
+ return (
1781
+ <Show when={text()}>
1782
+ <div data-component="reasoning-part" data-timeline-part-id={part().id}>
1783
+ <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
1784
+ <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
1785
+ </Show>
1786
+ </div>
1787
+ </Show>
1788
+ )
1789
+ }
1790
+
1791
+ ToolRegistry.register({
1792
+ name: "read",
1793
+ render(props) {
1794
+ const data = useData()
1795
+ const i18n = useI18n()
1796
+ const args: string[] = []
1797
+ if (props.input.offset) args.push("offset=" + props.input.offset)
1798
+ if (props.input.limit) args.push("limit=" + props.input.limit)
1799
+ const loaded = createMemo(() => {
1800
+ if (props.status !== "completed") return []
1801
+ const value = props.metadata.loaded
1802
+ if (!value || !Array.isArray(value)) return []
1803
+ return value.filter((p): p is string => typeof p === "string")
1804
+ })
1805
+ return (
1806
+ <>
1807
+ <BasicTool
1808
+ {...props}
1809
+ icon="glasses"
1810
+ trigger={{
1811
+ title: i18n.t("ui.tool.read"),
1812
+ subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
1813
+ args,
1814
+ }}
1815
+ />
1816
+ <For each={loaded()}>
1817
+ {(filepath) => (
1818
+ <div data-component="tool-loaded-file">
1819
+ <Icon name="enter" size="small" />
1820
+ <span>
1821
+ {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
1822
+ </span>
1823
+ </div>
1824
+ )}
1825
+ </For>
1826
+ </>
1827
+ )
1828
+ },
1829
+ })
1830
+
1831
+ ToolRegistry.register({
1832
+ name: "list",
1833
+ render(props) {
1834
+ const i18n = useI18n()
1835
+ return (
1836
+ <BasicTool
1837
+ {...props}
1838
+ icon="bullet-list"
1839
+ trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
1840
+ >
1841
+ <Show when={props.output}>
1842
+ <div
1843
+ data-component="tool-output"
1844
+ data-scrollable
1845
+ tabIndex={0}
1846
+ role="region"
1847
+ aria-label={i18n.t("ui.scrollView.ariaLabel")}
1848
+ >
1849
+ <Markdown text={props.output!} />
1850
+ </div>
1851
+ </Show>
1852
+ </BasicTool>
1853
+ )
1854
+ },
1855
+ })
1856
+
1857
+ ToolRegistry.register({
1858
+ name: "glob",
1859
+ render(props) {
1860
+ const i18n = useI18n()
1861
+ return (
1862
+ <BasicTool
1863
+ {...props}
1864
+ icon="magnifying-glass-menu"
1865
+ trigger={{
1866
+ title: i18n.t("ui.tool.glob"),
1867
+ subtitle: getDirectory(props.input.path || "/"),
1868
+ args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
1869
+ }}
1870
+ >
1871
+ <Show when={props.output}>
1872
+ <div
1873
+ data-component="tool-output"
1874
+ data-scrollable
1875
+ tabIndex={0}
1876
+ role="region"
1877
+ aria-label={i18n.t("ui.scrollView.ariaLabel")}
1878
+ >
1879
+ <Markdown text={props.output!} />
1880
+ </div>
1881
+ </Show>
1882
+ </BasicTool>
1883
+ )
1884
+ },
1885
+ })
1886
+
1887
+ ToolRegistry.register({
1888
+ name: "grep",
1889
+ render(props) {
1890
+ const i18n = useI18n()
1891
+ const args: string[] = []
1892
+ if (props.input.pattern) args.push("pattern=" + props.input.pattern)
1893
+ if (props.input.include) args.push("include=" + props.input.include)
1894
+ return (
1895
+ <BasicTool
1896
+ {...props}
1897
+ icon="magnifying-glass-menu"
1898
+ trigger={{
1899
+ title: i18n.t("ui.tool.grep"),
1900
+ subtitle: getDirectory(props.input.path || "/"),
1901
+ args,
1902
+ }}
1903
+ >
1904
+ <Show when={props.output}>
1905
+ <div
1906
+ data-component="tool-output"
1907
+ data-scrollable
1908
+ tabIndex={0}
1909
+ role="region"
1910
+ aria-label={i18n.t("ui.scrollView.ariaLabel")}
1911
+ >
1912
+ <Markdown text={props.output!} />
1913
+ </div>
1914
+ </Show>
1915
+ </BasicTool>
1916
+ )
1917
+ },
1918
+ })
1919
+
1920
+ ToolRegistry.register({
1921
+ name: "webfetch",
1922
+ render(props) {
1923
+ const i18n = useI18n()
1924
+ const pending = createMemo(() => props.status === "pending" || props.status === "running")
1925
+ const url = createMemo(() => {
1926
+ const value = props.input.url
1927
+ if (typeof value !== "string") return ""
1928
+ return value
1929
+ })
1930
+ return (
1931
+ <BasicTool
1932
+ {...props}
1933
+ hideDetails
1934
+ icon="window-cursor"
1935
+ trigger={
1936
+ <div data-slot="basic-tool-tool-info-structured">
1937
+ <div data-slot="basic-tool-tool-info-main">
1938
+ <span data-slot="basic-tool-tool-title">
1939
+ <TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
1940
+ </span>
1941
+ <Show when={!pending() && url()}>
1942
+ <a
1943
+ data-slot="basic-tool-tool-subtitle"
1944
+ class="clickable subagent-link"
1945
+ href={url()}
1946
+ target="_blank"
1947
+ rel="noopener noreferrer"
1948
+ onClick={(event) => event.stopPropagation()}
1949
+ >
1950
+ {url()}
1951
+ </a>
1952
+ </Show>
1953
+ </div>
1954
+ <Show when={!pending() && url()}>
1955
+ <div data-component="tool-action">
1956
+ <Icon name="square-arrow-top-right" size="small" />
1957
+ </div>
1958
+ </Show>
1959
+ </div>
1960
+ }
1961
+ />
1962
+ )
1963
+ },
1964
+ })
1965
+
1966
+ ToolRegistry.register({
1967
+ name: "websearch",
1968
+ render(props) {
1969
+ const query = createMemo(() => {
1970
+ const value = props.input.query
1971
+ if (typeof value !== "string") return ""
1972
+ return value
1973
+ })
1974
+ const title = createMemo(() => webSearchProviderLabel(props.metadata.provider))
1975
+
1976
+ return (
1977
+ <BasicTool
1978
+ {...props}
1979
+ icon="window-cursor"
1980
+ trigger={{
1981
+ title: title(),
1982
+ subtitle: query(),
1983
+ subtitleClass: "exa-tool-query",
1984
+ }}
1985
+ >
1986
+ <ExaOutput output={props.output} />
1987
+ </BasicTool>
1988
+ )
1989
+ },
1990
+ })
1991
+
1992
+ ToolRegistry.register({
1993
+ name: "task",
1994
+ render(props) {
1995
+ const data = useData()
1996
+ const i18n = useI18n()
1997
+ const location = useLocation()
1998
+ const childSessionId = createMemo(() => {
1999
+ const value = props.metadata.sessionId
2000
+ if (typeof value === "string" && value) return value
2001
+ return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
2002
+ })
2003
+ const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
2004
+ const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
2005
+ const tone = createMemo(() => agent().color)
2006
+ const v2Tone = createMemo(() => agent().v2Color)
2007
+ const subtitle = createMemo(() => {
2008
+ const value =
2009
+ typeof props.input.description === "string" && props.input.description
2010
+ ? props.input.description
2011
+ : childSessionId()
2012
+ if (!value) return value
2013
+ if (props.metadata.background === true) return `${value} (background)`
2014
+ return value
2015
+ })
2016
+ const running = createMemo(() => props.status === "pending" || props.status === "running")
2017
+
2018
+ const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
2019
+ const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
2020
+
2021
+ const open = () => {
2022
+ const id = childSessionId()
2023
+ if (!id) return
2024
+ if (data.navigateToSession) {
2025
+ data.navigateToSession(id)
2026
+ return
2027
+ }
2028
+ const value = href()
2029
+ if (value) window.location.assign(value)
2030
+ }
2031
+
2032
+ const navigate = (event: MouseEvent) => {
2033
+ if (!data.navigateToSession) return
2034
+ if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
2035
+ event.preventDefault()
2036
+ open()
2037
+ }
2038
+ const navigateKey = (event: KeyboardEvent) => {
2039
+ if (!clickable() || href()) return
2040
+ if (event.key !== "Enter" && event.key !== " ") return
2041
+ event.preventDefault()
2042
+ open()
2043
+ }
2044
+
2045
+ const trigger = () => (
2046
+ <div
2047
+ data-component="task-tool-card"
2048
+ style={{
2049
+ "--task-agent-color": v2Tone(),
2050
+ "--task-agent-legacy-color": tone(),
2051
+ }}
2052
+ >
2053
+ <div data-component="task-tool-surface">
2054
+ <div data-slot="basic-tool-tool-info-structured">
2055
+ <div data-slot="basic-tool-tool-info-main">
2056
+ <Show
2057
+ when={running()}
2058
+ fallback={
2059
+ <Show when={newLayout()}>
2060
+ <span data-component="task-tool-icon">
2061
+ <Icon name="subagent" size="small" />
2062
+ </span>
2063
+ </Show>
2064
+ }
2065
+ >
2066
+ <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
2067
+ <Show when={newLayout()} fallback={<Spinner />}>
2068
+ <SessionProgressIndicatorV2
2069
+ style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
2070
+ />
2071
+ </Show>
2072
+ </span>
2073
+ </Show>
2074
+ <span data-component="task-tool-title">{title()}</span>
2075
+ <Show when={subtitle()}>
2076
+ <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
2077
+ </Show>
2078
+ </div>
2079
+ </div>
2080
+ </div>
2081
+ <Show when={clickable()}>
2082
+ <div data-component="task-tool-action">
2083
+ <Icon name="square-arrow-top-right" size="small" />
2084
+ </div>
2085
+ </Show>
2086
+ </div>
2087
+ )
2088
+
2089
+ return (
2090
+ <BasicTool
2091
+ icon="task"
2092
+ status={props.status}
2093
+ trigger={trigger()}
2094
+ hideDetails
2095
+ triggerAsLink
2096
+ triggerHref={href()}
2097
+ clickable={clickable()}
2098
+ onTriggerClick={navigate}
2099
+ onTriggerKeyDown={navigateKey}
2100
+ />
2101
+ )
2102
+ },
2103
+ })
2104
+
2105
+ ToolRegistry.register({
2106
+ name: "shell",
2107
+ render(props) {
2108
+ const i18n = useI18n()
2109
+ const pending = () => props.status === "pending" || props.status === "running"
2110
+ const sawPending = pending()
2111
+ const text = createMemo(() => {
2112
+ const cmd = props.input.command ?? props.metadata.command ?? ""
2113
+ const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n")
2114
+ return `$ ${cmd}${out ? "\n\n" + out : ""}`
2115
+ })
2116
+ const [copied, setCopied] = createSignal(false)
2117
+
2118
+ const handleCopy = async () => {
2119
+ const content = text()
2120
+ if (!content) return
2121
+ if (await writeClipboard(content)) {
2122
+ setCopied(true)
2123
+ setTimeout(() => setCopied(false), 2000)
2124
+ }
2125
+ }
2126
+
2127
+ return (
2128
+ <BasicTool
2129
+ {...props}
2130
+ icon="console"
2131
+ allowOpenWhilePending
2132
+ trigger={(open) => (
2133
+ <div data-slot="basic-tool-tool-info-structured">
2134
+ <div data-slot="basic-tool-tool-info-main">
2135
+ <span data-slot="basic-tool-tool-title">
2136
+ <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
2137
+ </span>
2138
+ <Show when={!open() && props.input.command}>
2139
+ <ShellSubmessage text={props.input.command} animate={sawPending} />
2140
+ </Show>
2141
+ </div>
2142
+ </div>
2143
+ )}
2144
+ >
2145
+ <div data-component="bash-output">
2146
+ <div data-slot="bash-copy">
2147
+ <TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
2148
+ <IconButtonV2
2149
+ icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
2150
+ size="normal"
2151
+ variant="ghost-muted"
2152
+ onMouseDown={(e) => e.preventDefault()}
2153
+ onClick={handleCopy}
2154
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
2155
+ />
2156
+ </TooltipV2>
2157
+ </div>
2158
+ <div
2159
+ data-slot="bash-scroll"
2160
+ data-scrollable
2161
+ tabIndex={0}
2162
+ role="region"
2163
+ aria-label={i18n.t("ui.scrollView.ariaLabel")}
2164
+ >
2165
+ <pre data-slot="bash-pre">
2166
+ <code>{text()}</code>
2167
+ </pre>
2168
+ </div>
2169
+ </div>
2170
+ </BasicTool>
2171
+ )
2172
+ },
2173
+ })
2174
+
2175
+ ToolRegistry.register({
2176
+ name: "edit",
2177
+ render(props) {
2178
+ const i18n = useI18n()
2179
+ const fileComponent = useFileComponent()
2180
+ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
2181
+ const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
2182
+ const filename = () => getFilename(props.input.filePath ?? "")
2183
+ const pending = () => props.status === "pending" || props.status === "running"
2184
+ const diffSource = createMemo(
2185
+ () => {
2186
+ const filediff = props.metadata?.filediff
2187
+ if (!filediff) return
2188
+ return {
2189
+ file: filediff.file || props.input.filePath || "",
2190
+ patch: typeof filediff.patch === "string" ? filediff.patch : undefined,
2191
+ before: typeof filediff.before === "string" ? filediff.before : undefined,
2192
+ after: typeof filediff.after === "string" ? filediff.after : undefined,
2193
+ }
2194
+ },
2195
+ undefined,
2196
+ {
2197
+ equals: (a, b) =>
2198
+ a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after,
2199
+ },
2200
+ )
2201
+
2202
+ const fileCompProps = createMemo(() => {
2203
+ try {
2204
+ const source = diffSource()
2205
+ if (source) {
2206
+ const fileDiff = resolveFileDiff(source)
2207
+ if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
2208
+ }
2209
+ } catch {}
2210
+
2211
+ return {
2212
+ before: {
2213
+ name: props.metadata?.filediff?.file || props.input.filePath,
2214
+ contents: props.metadata?.filediff?.before || props.input.oldString || "",
2215
+ },
2216
+ after: {
2217
+ name: props.metadata?.filediff?.file || props.input.filePath,
2218
+ contents: props.metadata?.filediff?.after || props.input.newString || "",
2219
+ },
2220
+ }
2221
+ })
2222
+
2223
+ return (
2224
+ <div data-component="edit-tool">
2225
+ <BasicTool
2226
+ {...props}
2227
+ icon="code-lines"
2228
+ defer={props.deferContent !== false}
2229
+ trigger={
2230
+ <div data-component="edit-trigger">
2231
+ <div data-slot="message-part-title-area">
2232
+ <div data-slot="message-part-title">
2233
+ <span data-slot="message-part-title-text">
2234
+ <TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
2235
+ </span>
2236
+ <Show when={!pending()}>
2237
+ <span data-slot="message-part-title-filename">{filename()}</span>
2238
+ </Show>
2239
+ </div>
2240
+ <Show when={!pending() && props.input.filePath?.includes("/")}>
2241
+ <div data-slot="message-part-path">
2242
+ <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
2243
+ </div>
2244
+ </Show>
2245
+ </div>
2246
+ <div data-slot="message-part-actions">
2247
+ <Show when={!pending() && props.metadata.filediff}>
2248
+ <DiffChanges changes={props.metadata.filediff} />
2249
+ </Show>
2250
+ </div>
2251
+ </div>
2252
+ }
2253
+ >
2254
+ <Show when={path()}>
2255
+ <ToolFileAccordion
2256
+ path={path()}
2257
+ actions={
2258
+ <Show when={!pending() && props.metadata.filediff}>
2259
+ <DiffChanges changes={props.metadata.filediff!} />
2260
+ </Show>
2261
+ }
2262
+ >
2263
+ <div data-component="edit-content">
2264
+ <Dynamic
2265
+ component={fileComponent}
2266
+ mode="diff"
2267
+ virtualize={props.virtualizeDiff}
2268
+ onRendered={props.onContentRendered}
2269
+ {...fileCompProps()}
2270
+ />
2271
+ </div>
2272
+ </ToolFileAccordion>
2273
+ </Show>
2274
+ <DiagnosticsDisplay diagnostics={diagnostics()} />
2275
+ </BasicTool>
2276
+ </div>
2277
+ )
2278
+ },
2279
+ })
2280
+
2281
+ ToolRegistry.register({
2282
+ name: "write",
2283
+ render(props) {
2284
+ const i18n = useI18n()
2285
+ const fileComponent = useFileComponent()
2286
+ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
2287
+ const path = createMemo(() => props.input.filePath || "")
2288
+ const filename = () => getFilename(props.input.filePath ?? "")
2289
+ const pending = () => props.status === "pending" || props.status === "running"
2290
+ return (
2291
+ <div data-component="write-tool">
2292
+ <BasicTool
2293
+ {...props}
2294
+ icon="code-lines"
2295
+ defer={props.deferContent !== false}
2296
+ trigger={
2297
+ <div data-component="write-trigger">
2298
+ <div data-slot="message-part-title-area">
2299
+ <div data-slot="message-part-title">
2300
+ <span data-slot="message-part-title-text">
2301
+ <TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
2302
+ </span>
2303
+ <Show when={!pending()}>
2304
+ <span data-slot="message-part-title-filename">{filename()}</span>
2305
+ </Show>
2306
+ </div>
2307
+ <Show when={!pending() && props.input.filePath?.includes("/")}>
2308
+ <div data-slot="message-part-path">
2309
+ <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
2310
+ </div>
2311
+ </Show>
2312
+ </div>
2313
+ <div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
2314
+ </div>
2315
+ }
2316
+ >
2317
+ <Show when={props.input.content && path()}>
2318
+ <ToolFileAccordion path={path()}>
2319
+ <div data-component="write-content">
2320
+ <Dynamic
2321
+ component={fileComponent}
2322
+ mode="text"
2323
+ file={{
2324
+ name: props.input.filePath,
2325
+ contents: props.input.content,
2326
+ cacheKey: checksum(props.input.content),
2327
+ }}
2328
+ overflow="scroll"
2329
+ onRendered={props.onContentRendered}
2330
+ />
2331
+ </div>
2332
+ </ToolFileAccordion>
2333
+ </Show>
2334
+ <DiagnosticsDisplay diagnostics={diagnostics()} />
2335
+ </BasicTool>
2336
+ </div>
2337
+ )
2338
+ },
2339
+ })
2340
+
2341
+ ToolRegistry.register({
2342
+ name: "patch",
2343
+ render(props) {
2344
+ const i18n = useI18n()
2345
+ const fileComponent = useFileComponent()
2346
+ const files = createMemo(() => patchFiles(props.metadata.files))
2347
+ const pending = createMemo(() => props.status === "pending" || props.status === "running")
2348
+ const single = createMemo(() => {
2349
+ const list = files()
2350
+ if (list.length !== 1) return
2351
+ return list[0]
2352
+ })
2353
+ const [expanded, setExpanded] = createSignal<string[]>([])
2354
+ let seeded = false
2355
+
2356
+ createEffect(() => {
2357
+ const list = files()
2358
+ if (list.length === 0) return
2359
+ if (seeded) return
2360
+ seeded = true
2361
+ setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath))
2362
+ })
2363
+
2364
+ const subtitle = createMemo(() => {
2365
+ const count = files().length
2366
+ if (count === 0) return ""
2367
+ return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
2368
+ })
2369
+
2370
+ return (
2371
+ <Show
2372
+ when={single()}
2373
+ fallback={
2374
+ <div data-component="apply-patch-tool">
2375
+ <BasicTool
2376
+ {...props}
2377
+ icon="code-lines"
2378
+ defer={props.deferContent !== false}
2379
+ trigger={{
2380
+ title: i18n.t("ui.tool.patch"),
2381
+ subtitle: subtitle(),
2382
+ }}
2383
+ >
2384
+ <Show when={files().length > 0}>
2385
+ <Accordion
2386
+ multiple
2387
+ data-scope="apply-patch"
2388
+ style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
2389
+ value={expanded()}
2390
+ onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
2391
+ >
2392
+ <For each={files()}>
2393
+ {(file) => {
2394
+ const active = createMemo(() => expanded().includes(file.filePath))
2395
+ const [visible, setVisible] = createSignal(false)
2396
+
2397
+ createEffect(() => {
2398
+ if (!active()) {
2399
+ setVisible(false)
2400
+ return
2401
+ }
2402
+
2403
+ requestAnimationFrame(() => {
2404
+ if (!active()) return
2405
+ setVisible(true)
2406
+ })
2407
+ })
2408
+
2409
+ return (
2410
+ <Accordion.Item value={file.filePath} data-type={file.type}>
2411
+ <StickyAccordionHeader>
2412
+ <Accordion.Trigger>
2413
+ <div data-slot="apply-patch-trigger-content">
2414
+ <div data-slot="apply-patch-file-info">
2415
+ <FileIcon node={{ path: file.relativePath, type: "file" }} />
2416
+ <div data-slot="apply-patch-file-name-container">
2417
+ <Show when={file.relativePath.includes("/")}>
2418
+ <span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
2419
+ </Show>
2420
+ <span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
2421
+ </div>
2422
+ </div>
2423
+ <div data-slot="apply-patch-trigger-actions">
2424
+ <Switch>
2425
+ <Match when={file.type === "add"}>
2426
+ <span data-slot="apply-patch-change" data-type="added">
2427
+ {i18n.t("ui.patch.action.created")}
2428
+ </span>
2429
+ </Match>
2430
+ <Match when={file.type === "delete"}>
2431
+ <span data-slot="apply-patch-change" data-type="removed">
2432
+ {i18n.t("ui.patch.action.deleted")}
2433
+ </span>
2434
+ </Match>
2435
+ <Match when={file.type === "move"}>
2436
+ <span data-slot="apply-patch-change" data-type="modified">
2437
+ {i18n.t("ui.patch.action.moved")}
2438
+ </span>
2439
+ </Match>
2440
+ <Match when={true}>
2441
+ <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
2442
+ </Match>
2443
+ </Switch>
2444
+ <Icon name="chevron-grabber-vertical" size="small" />
2445
+ </div>
2446
+ </div>
2447
+ </Accordion.Trigger>
2448
+ </StickyAccordionHeader>
2449
+ <Accordion.Content>
2450
+ <Show when={props.deferContent === false || visible()}>
2451
+ <div data-component="apply-patch-file-diff">
2452
+ <Dynamic
2453
+ component={fileComponent}
2454
+ mode="diff"
2455
+ virtualize={props.virtualizeDiff}
2456
+ fileDiff={file.view.fileDiff}
2457
+ hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
2458
+ onRendered={props.onContentRendered}
2459
+ />
2460
+ </div>
2461
+ </Show>
2462
+ </Accordion.Content>
2463
+ </Accordion.Item>
2464
+ )
2465
+ }}
2466
+ </For>
2467
+ </Accordion>
2468
+ </Show>
2469
+ </BasicTool>
2470
+ </div>
2471
+ }
2472
+ >
2473
+ <div data-component="apply-patch-tool">
2474
+ <BasicTool
2475
+ {...props}
2476
+ icon="code-lines"
2477
+ defer={props.deferContent !== false}
2478
+ trigger={
2479
+ <div data-component="edit-trigger">
2480
+ <div data-slot="message-part-title-area">
2481
+ <div data-slot="message-part-title">
2482
+ <span data-slot="message-part-title-text">
2483
+ <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
2484
+ </span>
2485
+ <Show when={!pending()}>
2486
+ <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span>
2487
+ </Show>
2488
+ </div>
2489
+ <Show when={!pending() && single()!.relativePath.includes("/")}>
2490
+ <div data-slot="message-part-path">
2491
+ <span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span>
2492
+ </div>
2493
+ </Show>
2494
+ </div>
2495
+ <div data-slot="message-part-actions">
2496
+ <Show when={!pending()}>
2497
+ <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
2498
+ </Show>
2499
+ </div>
2500
+ </div>
2501
+ }
2502
+ >
2503
+ <ToolFileAccordion
2504
+ path={single()!.relativePath}
2505
+ actions={
2506
+ <Switch>
2507
+ <Match when={single()!.type === "add"}>
2508
+ <span data-slot="apply-patch-change" data-type="added">
2509
+ {i18n.t("ui.patch.action.created")}
2510
+ </span>
2511
+ </Match>
2512
+ <Match when={single()!.type === "delete"}>
2513
+ <span data-slot="apply-patch-change" data-type="removed">
2514
+ {i18n.t("ui.patch.action.deleted")}
2515
+ </span>
2516
+ </Match>
2517
+ <Match when={single()!.type === "move"}>
2518
+ <span data-slot="apply-patch-change" data-type="modified">
2519
+ {i18n.t("ui.patch.action.moved")}
2520
+ </span>
2521
+ </Match>
2522
+ <Match when={true}>
2523
+ <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
2524
+ </Match>
2525
+ </Switch>
2526
+ }
2527
+ >
2528
+ <div data-component="apply-patch-file-diff">
2529
+ <Dynamic
2530
+ component={fileComponent}
2531
+ mode="diff"
2532
+ virtualize={props.virtualizeDiff}
2533
+ fileDiff={single()!.view.fileDiff}
2534
+ onRendered={props.onContentRendered}
2535
+ />
2536
+ </div>
2537
+ </ToolFileAccordion>
2538
+ </BasicTool>
2539
+ </div>
2540
+ </Show>
2541
+ )
2542
+ },
2543
+ })
2544
+
2545
+ ToolRegistry.register({
2546
+ name: "todowrite",
2547
+ render(props) {
2548
+ const i18n = useI18n()
2549
+ const todos = createMemo(() => {
2550
+ const meta = props.metadata?.todos
2551
+ if (Array.isArray(meta)) return meta
2552
+
2553
+ const input = props.input.todos
2554
+ if (Array.isArray(input)) return input
2555
+
2556
+ return []
2557
+ })
2558
+
2559
+ const subtitle = createMemo(() => {
2560
+ const list = todos()
2561
+ if (list.length === 0) return ""
2562
+ return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}`
2563
+ })
2564
+
2565
+ return (
2566
+ <BasicTool
2567
+ {...props}
2568
+ defaultOpen
2569
+ icon="checklist"
2570
+ trigger={{
2571
+ title: i18n.t("ui.tool.todos"),
2572
+ subtitle: subtitle(),
2573
+ }}
2574
+ >
2575
+ <Show when={todos().length}>
2576
+ <div data-component="todos">
2577
+ <For each={todos()}>
2578
+ {(todo: Todo) => (
2579
+ <Checkbox readOnly checked={todo.status === "completed"}>
2580
+ <span
2581
+ data-slot="message-part-todo-content"
2582
+ data-completed={todo.status === "completed" ? "completed" : undefined}
2583
+ >
2584
+ {todo.content}
2585
+ </span>
2586
+ </Checkbox>
2587
+ )}
2588
+ </For>
2589
+ </div>
2590
+ </Show>
2591
+ </BasicTool>
2592
+ )
2593
+ },
2594
+ })
2595
+
2596
+ ToolRegistry.register({
2597
+ name: "question",
2598
+ render(props) {
2599
+ const i18n = useI18n()
2600
+ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
2601
+ const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
2602
+ const completed = createMemo(() => answers().length > 0)
2603
+
2604
+ const subtitle = createMemo(() => {
2605
+ const count = questions().length
2606
+ if (count === 0) return ""
2607
+ if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
2608
+ return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
2609
+ })
2610
+
2611
+ return (
2612
+ <BasicTool
2613
+ {...props}
2614
+ defaultOpen={completed()}
2615
+ icon="bubble-5"
2616
+ trigger={{
2617
+ title: i18n.t("ui.tool.questions"),
2618
+ subtitle: subtitle(),
2619
+ }}
2620
+ >
2621
+ <Show when={completed()}>
2622
+ <div data-component="question-answers">
2623
+ <For each={questions()}>
2624
+ {(q, i) => {
2625
+ const answer = () => answers()[i()] ?? []
2626
+ return (
2627
+ <div data-slot="question-answer-item">
2628
+ <div data-slot="question-text">{q.question}</div>
2629
+ <div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
2630
+ </div>
2631
+ )
2632
+ }}
2633
+ </For>
2634
+ </div>
2635
+ </Show>
2636
+ </BasicTool>
2637
+ )
2638
+ },
2639
+ })
2640
+
2641
+ ToolRegistry.register({
2642
+ name: "skill",
2643
+ render(props) {
2644
+ const i18n = useI18n()
2645
+ const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill"))
2646
+ const running = createMemo(() => props.status === "pending" || props.status === "running")
2647
+
2648
+ const titleContent = () => <TextShimmer text={title()} active={running()} />
2649
+
2650
+ const trigger = () => (
2651
+ <div data-slot="basic-tool-tool-info-structured">
2652
+ <div data-slot="basic-tool-tool-info-main">
2653
+ <span data-slot="basic-tool-tool-title" class="capitalize agent-title">
2654
+ {titleContent()}
2655
+ </span>
2656
+ </div>
2657
+ </div>
2658
+ )
2659
+
2660
+ return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails />
2661
+ },
2662
+ })