@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,543 @@
1
+ import {
2
+ AssistantMessage,
3
+ type SnapshotFileDiff,
4
+ Message as MessageType,
5
+ Part as PartType,
6
+ } from "@neurocode-ai/sdk/v2/client"
7
+ import type { FileDiffInfo } from "@neurocode-ai/client/promise"
8
+ import type { SessionStatus } from "@neurocode-ai/sdk/v2"
9
+ import { useData } from "../context"
10
+ import { useFileComponent } from "@neurocode-ai/ui/context/file"
11
+
12
+ import { Binary } from "@neurocode-ai/core/util/binary"
13
+ import { getDirectory, getFilename } from "@neurocode-ai/core/util/path"
14
+ import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
15
+ import { createStore } from "solid-js/store"
16
+ import { Dynamic } from "solid-js/web"
17
+ import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
18
+ import { Card } from "@neurocode-ai/ui/card"
19
+ import { Accordion } from "@neurocode-ai/ui/accordion"
20
+ import { StickyAccordionHeader } from "@neurocode-ai/ui/sticky-accordion-header"
21
+ import { DiffChanges } from "@neurocode-ai/ui/diff-changes"
22
+ import { Icon } from "@neurocode-ai/ui/icon"
23
+ import { TextShimmer } from "@neurocode-ai/ui/text-shimmer"
24
+ import { SessionRetry } from "./session-retry"
25
+ import { TextReveal } from "@neurocode-ai/ui/text-reveal"
26
+ import { createAutoScroll } from "@neurocode-ai/ui/hooks"
27
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
28
+ import { normalize } from "./session-diff"
29
+
30
+ function record(value: unknown): value is Record<string, unknown> {
31
+ return !!value && typeof value === "object" && !Array.isArray(value)
32
+ }
33
+
34
+ function unwrap(message: string) {
35
+ const text = message.replace(/^Error:\s*/, "").trim()
36
+
37
+ const parse = (value: string) => {
38
+ try {
39
+ return JSON.parse(value) as unknown
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ const read = (value: string) => {
46
+ const first = parse(value)
47
+ if (typeof first !== "string") return first
48
+ return parse(first.trim())
49
+ }
50
+
51
+ let json = read(text)
52
+
53
+ if (json === undefined) {
54
+ const start = text.indexOf("{")
55
+ const end = text.lastIndexOf("}")
56
+ if (start !== -1 && end > start) {
57
+ json = read(text.slice(start, end + 1))
58
+ }
59
+ }
60
+
61
+ if (!record(json)) return message
62
+
63
+ const err = record(json.error) ? json.error : undefined
64
+ if (err) {
65
+ const type = typeof err.type === "string" ? err.type : undefined
66
+ const msg = typeof err.message === "string" ? err.message : undefined
67
+ if (type && msg) return `${type}: ${msg}`
68
+ if (msg) return msg
69
+ if (type) return type
70
+ const code = typeof err.code === "string" ? err.code : undefined
71
+ if (code) return code
72
+ }
73
+
74
+ const msg = typeof json.message === "string" ? json.message : undefined
75
+ if (msg) return msg
76
+
77
+ const reason = typeof json.error === "string" ? json.error : undefined
78
+ if (reason) return reason
79
+
80
+ return message
81
+ }
82
+
83
+ function same<T>(a: readonly T[], b: readonly T[]) {
84
+ if (a === b) return true
85
+ if (a.length !== b.length) return false
86
+ return a.every((x, i) => x === b[i])
87
+ }
88
+
89
+ function list<T>(value: T[] | undefined | null, fallback: T[]) {
90
+ if (Array.isArray(value)) return value
91
+ return fallback
92
+ }
93
+
94
+ type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
95
+
96
+ function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
97
+ return typeof value.file === "string"
98
+ }
99
+
100
+ const hidden = new Set(["todowrite"])
101
+
102
+ function partState(part: PartType, showReasoningSummaries: boolean) {
103
+ if (part.type === "tool") {
104
+ if (hidden.has(part.tool)) return
105
+ if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
106
+ return "visible" as const
107
+ }
108
+ if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
109
+ if (part.type === "reasoning") {
110
+ if (showReasoningSummaries && part.text?.trim()) return "visible" as const
111
+ return
112
+ }
113
+ if (PART_MAPPING[part.type]) return "visible" as const
114
+ return
115
+ }
116
+
117
+ function clean(value: string) {
118
+ return value
119
+ .replace(/`([^`]+)`/g, "$1")
120
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
121
+ .replace(/[*_~]+/g, "")
122
+ .trim()
123
+ }
124
+
125
+ function heading(text: string) {
126
+ const markdown = text.replace(/\r\n?/g, "\n")
127
+
128
+ const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
129
+ if (html?.[1]) {
130
+ const value = clean(html[1].replace(/<[^>]+>/g, " "))
131
+ if (value) return value
132
+ }
133
+
134
+ const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
135
+ if (atx?.[1]) {
136
+ const value = clean(atx[1])
137
+ if (value) return value
138
+ }
139
+
140
+ const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
141
+ if (setext?.[1]) {
142
+ const value = clean(setext[1])
143
+ if (value) return value
144
+ }
145
+
146
+ const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
147
+ if (strong?.[1]) {
148
+ const value = clean(strong[1])
149
+ if (value) return value
150
+ }
151
+ }
152
+
153
+ export function SessionTurn(
154
+ props: ParentProps<{
155
+ sessionID: string
156
+ messageID: string
157
+ messages?: MessageType[]
158
+ actions?: UserActions
159
+ showReasoningSummaries?: boolean
160
+ shellToolDefaultOpen?: boolean
161
+ editToolDefaultOpen?: boolean
162
+ active?: boolean
163
+ status?: SessionStatus
164
+ onUserInteracted?: () => void
165
+ classes?: {
166
+ root?: string
167
+ content?: string
168
+ container?: string
169
+ }
170
+ }>,
171
+ ) {
172
+ const data = useData()
173
+ const i18n = useI18n()
174
+ const fileComponent = useFileComponent()
175
+
176
+ const emptyMessages: MessageType[] = []
177
+ const emptyParts: PartType[] = []
178
+ const emptyAssistant: AssistantMessage[] = []
179
+ const emptyDiffs: SummaryDiff[] = []
180
+ const idle = { type: "idle" as const }
181
+
182
+ const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
183
+
184
+ const messageIndex = createMemo(() => {
185
+ const messages = allMessages() ?? emptyMessages
186
+ const result = Binary.search(messages, props.messageID, (m) => m.id)
187
+
188
+ const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID)
189
+ if (index < 0) return -1
190
+
191
+ const msg = messages[index]
192
+ if (!msg || msg.role !== "user") return -1
193
+
194
+ return index
195
+ })
196
+
197
+ const message = createMemo(() => {
198
+ const index = messageIndex()
199
+ if (index < 0) return undefined
200
+
201
+ const messages = allMessages() ?? emptyMessages
202
+ const msg = messages[index]
203
+ if (!msg || msg.role !== "user") return undefined
204
+
205
+ return msg
206
+ })
207
+
208
+ const pending = createMemo(() => {
209
+ if (typeof props.active === "boolean") return
210
+ const messages = allMessages() ?? emptyMessages
211
+ return messages.findLast(
212
+ (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
213
+ )
214
+ })
215
+
216
+ const pendingUser = createMemo(() => {
217
+ const item = pending()
218
+ if (!item?.parentID) return
219
+ const messages = allMessages() ?? emptyMessages
220
+ const result = Binary.search(messages, item.parentID, (m) => m.id)
221
+ const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
222
+ if (!msg || msg.role !== "user") return
223
+ return msg
224
+ })
225
+
226
+ const active = createMemo(() => {
227
+ if (typeof props.active === "boolean") return props.active
228
+ const msg = message()
229
+ const parent = pendingUser()
230
+ if (!msg || !parent) return false
231
+ return parent.id === msg.id
232
+ })
233
+
234
+ const parts = createMemo(() => {
235
+ const msg = message()
236
+ if (!msg) return emptyParts
237
+ return list(data.store.part?.[msg.id], emptyParts)
238
+ })
239
+
240
+ const compaction = createMemo(() => parts().find((part) => part.type === "compaction"))
241
+
242
+ const diffs = createMemo(() => {
243
+ const files = message()?.summary?.diffs
244
+ if (!files?.length) return emptyDiffs
245
+
246
+ const seen = new Set<string>()
247
+ return files
248
+ .reduceRight<SummaryDiff[]>((result, diff) => {
249
+ if (!summaryDiff(diff)) return result
250
+ if (seen.has(diff.file)) return result
251
+ seen.add(diff.file)
252
+ result.push(diff)
253
+ return result
254
+ }, [])
255
+ .reverse()
256
+ })
257
+ const MAX_FILES = 10
258
+ const edited = createMemo(() => diffs().length)
259
+ const [state, setState] = createStore({
260
+ showAll: false,
261
+ expanded: [] as string[],
262
+ })
263
+ const showAll = () => state.showAll
264
+ const expanded = () => state.expanded
265
+ const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
266
+ const visible = createMemo(() => (showAll() ? diffs() : diffs().slice(0, MAX_FILES)))
267
+ const toggleAll = () => {
268
+ autoScroll.pause()
269
+ setState("showAll", !showAll())
270
+ }
271
+
272
+ const assistantMessages = createMemo(
273
+ () => {
274
+ const msg = message()
275
+ if (!msg) return emptyAssistant
276
+
277
+ const messages = allMessages() ?? emptyMessages
278
+ if (messageIndex() < 0) return emptyAssistant
279
+
280
+ const result: AssistantMessage[] = []
281
+ for (let i = 0; i < messages.length; i++) {
282
+ const item = messages[i]
283
+ if (!item) continue
284
+ if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
285
+ }
286
+ return result
287
+ },
288
+ emptyAssistant,
289
+ { equals: same },
290
+ )
291
+
292
+ const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
293
+ const divider = createMemo(() => {
294
+ if (compaction()) return i18n.t("ui.messagePart.compaction")
295
+ if (interrupted()) return i18n.t("ui.message.interrupted")
296
+ return ""
297
+ })
298
+ const error = createMemo(
299
+ () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
300
+ )
301
+ const showAssistantCopyPartID = createMemo(() => {
302
+ const messages = assistantMessages()
303
+
304
+ for (let i = messages.length - 1; i >= 0; i--) {
305
+ const message = messages[i]
306
+ if (!message) continue
307
+
308
+ const parts = list(data.store.part?.[message.id], emptyParts)
309
+ for (let j = parts.length - 1; j >= 0; j--) {
310
+ const part = parts[j]
311
+ if (!part || part.type !== "text" || !part.text?.trim()) continue
312
+ return part.id
313
+ }
314
+ }
315
+
316
+ return undefined
317
+ })
318
+ const errorText = createMemo(() => {
319
+ const msg = error()?.data?.message
320
+ if (typeof msg === "string") return unwrap(msg)
321
+ if (msg === undefined || msg === null) return ""
322
+ // oxlint-disable-next-line no-base-to-string -- msg is unknown from error data, coercion is intentional
323
+ return unwrap(String(msg))
324
+ })
325
+
326
+ const status = createMemo(() => {
327
+ if (props.status !== undefined) return props.status
328
+ if (typeof props.active === "boolean" && !props.active) return idle
329
+ return data.store.session_status[props.sessionID] ?? idle
330
+ })
331
+ const working = createMemo(() => status().type !== "idle" && active())
332
+ const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
333
+
334
+ const assistantCopyPartID = createMemo(() => {
335
+ if (working()) return null
336
+ return showAssistantCopyPartID() ?? null
337
+ })
338
+ const turnDurationMs = createMemo(() => {
339
+ const start = message()?.time.created
340
+ if (typeof start !== "number") return undefined
341
+
342
+ const end = assistantMessages().reduce<number | undefined>((max, item) => {
343
+ const completed = item.time.completed
344
+ if (typeof completed !== "number") return max
345
+ if (max === undefined) return completed
346
+ return Math.max(max, completed)
347
+ }, undefined)
348
+
349
+ if (typeof end !== "number") return undefined
350
+ if (end < start) return undefined
351
+ return end - start
352
+ })
353
+ const assistantDerived = createMemo(() => {
354
+ let visible = 0
355
+ let reason: string | undefined
356
+ const show = showReasoningSummaries()
357
+ for (const message of assistantMessages()) {
358
+ for (const part of list(data.store.part?.[message.id], emptyParts)) {
359
+ if (partState(part, show) === "visible") {
360
+ visible++
361
+ }
362
+ if (part.type === "reasoning" && part.text) {
363
+ const h = heading(part.text)
364
+ if (h) reason = h
365
+ }
366
+ }
367
+ }
368
+ return { visible, reason }
369
+ })
370
+ const assistantVisible = createMemo(() => assistantDerived().visible)
371
+ const reasoningHeading = createMemo(() => assistantDerived().reason)
372
+ const showThinking = createMemo(() => {
373
+ if (!working() || !!error()) return false
374
+ if (status().type === "retry") return false
375
+ if (showReasoningSummaries()) return assistantVisible() === 0
376
+ return true
377
+ })
378
+
379
+ const autoScroll = createAutoScroll({
380
+ working,
381
+ onUserInteracted: props.onUserInteracted,
382
+ overflowAnchor: "dynamic",
383
+ })
384
+
385
+ return (
386
+ <div data-component="session-turn" class={props.classes?.root}>
387
+ <div
388
+ ref={autoScroll.scrollRef}
389
+ onScroll={autoScroll.handleScroll}
390
+ data-slot="session-turn-content"
391
+ class={props.classes?.content}
392
+ >
393
+ <div onClick={autoScroll.handleInteraction}>
394
+ <Show when={message()}>
395
+ <div
396
+ ref={autoScroll.contentRef}
397
+ data-message={message()!.id}
398
+ data-slot="session-turn-message-container"
399
+ class={props.classes?.container}
400
+ >
401
+ <div data-slot="session-turn-message-content" aria-live="off">
402
+ <Message message={message()!} parts={parts()} actions={props.actions} />
403
+ </div>
404
+ <Show when={divider()}>
405
+ <div data-slot="session-turn-compaction">
406
+ <MessageDivider label={divider()} />
407
+ </div>
408
+ </Show>
409
+ <Show when={assistantMessages().length > 0}>
410
+ <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
411
+ <AssistantParts
412
+ messages={assistantMessages()}
413
+ showAssistantCopyPartID={assistantCopyPartID()}
414
+ turnDurationMs={turnDurationMs()}
415
+ working={working()}
416
+ showReasoningSummaries={showReasoningSummaries()}
417
+ shellToolDefaultOpen={props.shellToolDefaultOpen}
418
+ editToolDefaultOpen={props.editToolDefaultOpen}
419
+ />
420
+ </div>
421
+ </Show>
422
+ <Show when={showThinking()}>
423
+ <div data-slot="session-turn-thinking">
424
+ <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
425
+ <Show when={!showReasoningSummaries()}>
426
+ <TextReveal
427
+ text={reasoningHeading()}
428
+ class="session-turn-thinking-heading"
429
+ travel={25}
430
+ duration={700}
431
+ />
432
+ </Show>
433
+ </div>
434
+ </Show>
435
+ <SessionRetry status={status()} show={active()} />
436
+ <Show when={edited() > 0 && !working()}>
437
+ <div
438
+ data-slot="session-turn-diffs"
439
+ data-component="session-turn-diffs-group"
440
+ data-show-all={showAll() || undefined}
441
+ >
442
+ <div data-slot="session-turn-diffs-header">
443
+ <span data-slot="session-turn-diffs-label">
444
+ {i18n.t(
445
+ edited() === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
446
+ { count: String(edited()) },
447
+ )}
448
+ </span>
449
+ <DiffChanges changes={diffs()} />
450
+ <Show when={overflow() > 0}>
451
+ <span data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
452
+ {showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
453
+ </span>
454
+ </Show>
455
+ </div>
456
+ <div data-component="session-turn-diffs-content">
457
+ <Accordion
458
+ multiple
459
+ style={{ "--sticky-accordion-offset": "44px" }}
460
+ value={expanded()}
461
+ onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
462
+ >
463
+ <For each={visible()}>
464
+ {(diff) => {
465
+ const view = normalize(diff)
466
+ const active = createMemo(() => expanded().includes(diff.file))
467
+ const [shown, setShown] = createSignal(false)
468
+
469
+ createEffect(
470
+ on(
471
+ active,
472
+ (value) => {
473
+ if (!value) {
474
+ setShown(false)
475
+ return
476
+ }
477
+
478
+ requestAnimationFrame(() => {
479
+ if (!active()) return
480
+ setShown(true)
481
+ })
482
+ },
483
+ { defer: true },
484
+ ),
485
+ )
486
+
487
+ return (
488
+ <Accordion.Item value={diff.file}>
489
+ <StickyAccordionHeader>
490
+ <Accordion.Trigger>
491
+ <div data-slot="session-turn-diff-trigger">
492
+ <span data-slot="session-turn-diff-path">
493
+ <Show when={diff.file.includes("/")}>
494
+ <span data-slot="session-turn-diff-directory">
495
+ {`\u202A${getDirectory(diff.file)}\u202C`}
496
+ </span>
497
+ </Show>
498
+ <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
499
+ </span>
500
+ <div data-slot="session-turn-diff-meta">
501
+ <span data-slot="session-turn-diff-changes">
502
+ <DiffChanges changes={diff} />
503
+ </span>
504
+ <span data-slot="session-turn-diff-chevron">
505
+ <Icon name="chevron-down" size="small" />
506
+ </span>
507
+ </div>
508
+ </div>
509
+ </Accordion.Trigger>
510
+ </StickyAccordionHeader>
511
+ <Accordion.Content>
512
+ <Show when={shown()}>
513
+ <div data-slot="session-turn-diff-view" data-scrollable>
514
+ <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
515
+ </div>
516
+ </Show>
517
+ </Accordion.Content>
518
+ </Accordion.Item>
519
+ )
520
+ }}
521
+ </For>
522
+ </Accordion>
523
+ <Show when={!showAll() && overflow() > 0}>
524
+ <div data-slot="session-turn-diffs-more" onClick={toggleAll}>
525
+ {i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
526
+ </div>
527
+ </Show>
528
+ </div>
529
+ </div>
530
+ </Show>
531
+ <Show when={error()}>
532
+ <Card variant="error" class="error-card">
533
+ {errorText()}
534
+ </Card>
535
+ </Show>
536
+ </div>
537
+ </Show>
538
+ {props.children}
539
+ </div>
540
+ </div>
541
+ </div>
542
+ )
543
+ }