@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,657 @@
1
+ import { Accordion } from "@neurocode-ai/ui/accordion"
2
+ import { Button } from "@neurocode-ai/ui/button"
3
+ import { DropdownMenu } from "@neurocode-ai/ui/dropdown-menu"
4
+ import { RadioGroup } from "@neurocode-ai/ui/radio-group"
5
+ import { DiffChanges } from "@neurocode-ai/ui/diff-changes"
6
+ import { FileIcon } from "@neurocode-ai/ui/file-icon"
7
+ import { Icon } from "@neurocode-ai/ui/icon"
8
+ import { IconButton } from "@neurocode-ai/ui/icon-button"
9
+ import { StickyAccordionHeader } from "@neurocode-ai/ui/sticky-accordion-header"
10
+ import { Tooltip } from "@neurocode-ai/ui/tooltip"
11
+ import { ScrollView } from "@neurocode-ai/ui/scroll-view"
12
+ import { useFileComponent } from "@neurocode-ai/ui/context/file"
13
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
14
+ import { getDirectory, getFilename } from "@neurocode-ai/core/util/path"
15
+ import { checksum } from "@neurocode-ai/core/util/encode"
16
+ import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
17
+ import { createStore } from "solid-js/store"
18
+ import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@neurocode-ai/sdk/v2"
19
+ import type { FileDiffInfo } from "@neurocode-ai/client/promise"
20
+ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
21
+ import { type SelectedLineRange } from "@pierre/diffs"
22
+ import { Dynamic } from "solid-js/web"
23
+ import { mediaKindFromPath } from "../pierre/media"
24
+ import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
25
+ import { createLineCommentController } from "./line-comment-annotations"
26
+ import type { LineCommentEditorProps } from "./line-comment"
27
+ import { normalize, text, type ViewDiff } from "./session-diff"
28
+
29
+ const MAX_DIFF_CHANGED_LINES = 500
30
+ const REVIEW_MOUNT_MARGIN = 300
31
+
32
+ export type SessionReviewDiffStyle = "unified" | "split"
33
+
34
+ export type SessionReviewComment = {
35
+ id: string
36
+ file: string
37
+ selection: SelectedLineRange
38
+ comment: string
39
+ }
40
+
41
+ export type SessionReviewLineComment = {
42
+ file: string
43
+ selection: SelectedLineRange
44
+ comment: string
45
+ preview?: string
46
+ }
47
+
48
+ export type SessionReviewCommentUpdate = SessionReviewLineComment & {
49
+ id: string
50
+ }
51
+
52
+ export type SessionReviewCommentDelete = {
53
+ id: string
54
+ file: string
55
+ }
56
+
57
+ export type SessionReviewCommentActions = {
58
+ moreLabel: string
59
+ editLabel: string
60
+ deleteLabel: string
61
+ saveLabel: string
62
+ }
63
+
64
+ export type SessionReviewFocus = { file: string; id: string }
65
+
66
+ type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
67
+ preloaded?: PreloadMultiFileDiffResult<any>
68
+ }
69
+ type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
70
+ preloaded?: PreloadMultiFileDiffResult<any>
71
+ }
72
+ type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
73
+
74
+ function diff(value: unknown): value is ReviewDiff {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
76
+ if (!("file" in value) || typeof value.file !== "string") return false
77
+ if (!("additions" in value) || typeof value.additions !== "number") return false
78
+ if (!("deletions" in value) || typeof value.deletions !== "number") return false
79
+ if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false
80
+ if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false
81
+ if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false
82
+ if (!("status" in value) || value.status === undefined) return true
83
+ return value.status === "added" || value.status === "deleted" || value.status === "modified"
84
+ }
85
+
86
+ function list(value: unknown): ReviewDiff[] {
87
+ if (Array.isArray(value) && value.every(diff)) return value
88
+ if (Array.isArray(value)) return value.filter(diff)
89
+ if (diff(value)) return [value]
90
+ if (!value || typeof value !== "object") return []
91
+ return Object.values(value).filter(diff)
92
+ }
93
+
94
+ export interface SessionReviewProps {
95
+ title?: JSX.Element
96
+ empty?: JSX.Element
97
+ split?: boolean
98
+ diffStyle?: SessionReviewDiffStyle
99
+ onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
100
+ onDiffRendered?: VoidFunction
101
+ onLineComment?: (comment: SessionReviewLineComment) => void
102
+ onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
103
+ onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
104
+ lineCommentActions?: SessionReviewCommentActions
105
+ comments?: SessionReviewComment[]
106
+ focusedComment?: SessionReviewFocus | null
107
+ onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
108
+ focusedFile?: string
109
+ open?: string[]
110
+ onOpenChange?: (open: string[]) => void
111
+ scrollRef?: (el: HTMLDivElement) => void
112
+ onScroll?: JSX.EventHandlerUnion<HTMLDivElement, Event>
113
+ class?: string
114
+ classList?: Record<string, boolean | undefined>
115
+ classes?: { root?: string; header?: string; container?: string }
116
+ actions?: JSX.Element
117
+ diffs: RawReviewDiff[]
118
+ onViewFile?: (file: string) => void
119
+ readFile?: (path: string) => Promise<FileContent | undefined>
120
+ lineCommentMention?: LineCommentEditorProps["mention"]
121
+ }
122
+
123
+ function ReviewCommentMenu(props: {
124
+ labels: SessionReviewCommentActions
125
+ onEdit: VoidFunction
126
+ onDelete: VoidFunction
127
+ }) {
128
+ return (
129
+ <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
130
+ <DropdownMenu gutter={4} placement="bottom-end">
131
+ <DropdownMenu.Trigger
132
+ as={IconButton}
133
+ icon="dot-grid"
134
+ variant="ghost"
135
+ size="small"
136
+ class="size-6 rounded-md"
137
+ aria-label={props.labels.moreLabel}
138
+ />
139
+ <DropdownMenu.Portal>
140
+ <DropdownMenu.Content>
141
+ <DropdownMenu.Item onSelect={props.onEdit}>
142
+ <DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
143
+ </DropdownMenu.Item>
144
+ <DropdownMenu.Item onSelect={props.onDelete}>
145
+ <DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
146
+ </DropdownMenu.Item>
147
+ </DropdownMenu.Content>
148
+ </DropdownMenu.Portal>
149
+ </DropdownMenu>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ function diffId(file: string): string | undefined {
155
+ const sum = checksum(file)
156
+ if (!sum) return
157
+ return `session-review-diff-${sum}`
158
+ }
159
+
160
+ type SessionReviewSelection = {
161
+ file: string
162
+ range: SelectedLineRange
163
+ }
164
+
165
+ export const SessionReview = (props: SessionReviewProps) => {
166
+ let scroll: HTMLDivElement | undefined
167
+ let focusToken = 0
168
+ let frame: number | undefined
169
+ const i18n = useI18n()
170
+ const fileComponent = useFileComponent()
171
+ const anchors = new Map<string, HTMLElement>()
172
+ const nodes = new Map<string, HTMLDivElement>()
173
+ const [store, setStore] = createStore({
174
+ open: [] as string[],
175
+ visible: {} as Record<string, boolean>,
176
+ force: {} as Record<string, boolean>,
177
+ selection: null as SessionReviewSelection | null,
178
+ commenting: null as SessionReviewSelection | null,
179
+ opened: null as SessionReviewFocus | null,
180
+ })
181
+ const selection = () => store.selection
182
+ const commenting = () => store.commenting
183
+ const opened = () => store.opened
184
+
185
+ const open = () => props.open ?? store.open
186
+ const itemsMap = createMemo(() =>
187
+ Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])),
188
+ )
189
+ const files = createMemo(() => props.diffs.map((diff) => diff.file!))
190
+ const grouped = createMemo(() => {
191
+ const next = new Map<string, SessionReviewComment[]>()
192
+ for (const comment of props.comments ?? []) {
193
+ const list = next.get(comment.file)
194
+ if (list) {
195
+ list.push(comment)
196
+ continue
197
+ }
198
+ next.set(comment.file, [comment])
199
+ }
200
+ return next
201
+ })
202
+ const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
203
+ const hasDiffs = () => files().length > 0
204
+
205
+ const syncVisible = () => {
206
+ frame = undefined
207
+ if (!scroll) return
208
+
209
+ const root = scroll.getBoundingClientRect()
210
+ const top = root.top - REVIEW_MOUNT_MARGIN
211
+ const bottom = root.bottom + REVIEW_MOUNT_MARGIN
212
+ const openSet = new Set(open())
213
+ const next: Record<string, boolean> = {}
214
+
215
+ for (const [file, el] of nodes) {
216
+ if (!openSet.has(file)) continue
217
+ const rect = el.getBoundingClientRect()
218
+ if (rect.bottom < top || rect.top > bottom) continue
219
+ next[file] = true
220
+ }
221
+
222
+ const prev = untrack(() => store.visible)
223
+ const prevKeys = Object.keys(prev)
224
+ const nextKeys = Object.keys(next)
225
+ if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
226
+ setStore("visible", next)
227
+ }
228
+
229
+ const queue = () => {
230
+ if (frame !== undefined) return
231
+ frame = requestAnimationFrame(syncVisible)
232
+ }
233
+
234
+ const pinned = (file: string) =>
235
+ props.focusedComment?.file === file ||
236
+ props.focusedFile === file ||
237
+ selection()?.file === file ||
238
+ commenting()?.file === file ||
239
+ opened()?.file === file
240
+
241
+ const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
242
+ queue()
243
+ const next = props.onScroll
244
+ if (!next) return
245
+ if (Array.isArray(next)) {
246
+ const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
247
+ fn(data, event)
248
+ return
249
+ }
250
+ ;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
251
+ }
252
+
253
+ onCleanup(() => {
254
+ if (frame === undefined) return
255
+ cancelAnimationFrame(frame)
256
+ })
257
+
258
+ createEffect(() => {
259
+ props.open
260
+ files()
261
+ queue()
262
+ })
263
+
264
+ const handleChange = (next: string[]) => {
265
+ props.onOpenChange?.(next)
266
+ if (props.open === undefined) setStore("open", next)
267
+ queue()
268
+ }
269
+
270
+ const handleExpandOrCollapseAll = () => {
271
+ const next = open().length > 0 ? [] : files()
272
+ handleChange(next)
273
+ }
274
+
275
+ const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
276
+
277
+ const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
278
+
279
+ const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
280
+ const side = selectionSide(range)
281
+ const contents = text(diff, side)
282
+ if (contents.length === 0) return undefined
283
+
284
+ return previewSelectedLines(contents, range)
285
+ }
286
+
287
+ createEffect(() => {
288
+ const focus = props.focusedComment
289
+ if (!focus) return
290
+
291
+ untrack(() => {
292
+ focusToken++
293
+ const token = focusToken
294
+
295
+ setStore("opened", focus)
296
+
297
+ const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
298
+ if (comment) setStore("selection", { file: comment.file, range: cloneSelectedLineRange(comment.selection) })
299
+
300
+ const current = open()
301
+ if (!current.includes(focus.file)) {
302
+ handleChange([...current, focus.file])
303
+ }
304
+
305
+ const scrollTo = (attempt: number) => {
306
+ if (token !== focusToken) return
307
+
308
+ const root = scroll
309
+ if (!root) return
310
+
311
+ const wrapper = anchors.get(focus.file)
312
+ const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
313
+ const ready =
314
+ anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
315
+
316
+ const target = ready ? anchor : wrapper
317
+ if (!target) {
318
+ if (attempt >= 120) return
319
+ requestAnimationFrame(() => scrollTo(attempt + 1))
320
+ return
321
+ }
322
+
323
+ const rootRect = root.getBoundingClientRect()
324
+ const targetRect = target.getBoundingClientRect()
325
+ const offset = targetRect.top - rootRect.top
326
+ const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
327
+ root.scrollTop = Math.max(0, next)
328
+
329
+ if (ready) return
330
+ if (attempt >= 120) return
331
+ requestAnimationFrame(() => scrollTo(attempt + 1))
332
+ }
333
+
334
+ requestAnimationFrame(() => scrollTo(0))
335
+
336
+ requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
337
+ })
338
+ })
339
+
340
+ return (
341
+ <div data-component="session-review" class={props.class} classList={props.classList}>
342
+ <div data-slot="session-review-header" class={props.classes?.header}>
343
+ <div data-slot="session-review-title">
344
+ {props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
345
+ </div>
346
+ <div data-slot="session-review-actions">
347
+ <Show when={hasDiffs() && props.onDiffStyleChange}>
348
+ <RadioGroup
349
+ options={["unified", "split"] as const}
350
+ current={diffStyle()}
351
+ size="small"
352
+ value={(style) => style}
353
+ label={(style) =>
354
+ i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split")
355
+ }
356
+ onSelect={(style) => style && props.onDiffStyleChange?.(style)}
357
+ />
358
+ </Show>
359
+ <Show when={hasDiffs()}>
360
+ <Button
361
+ size="small"
362
+ icon="chevron-grabber-vertical"
363
+ class="w-[106px] justify-start"
364
+ onClick={handleExpandOrCollapseAll}
365
+ >
366
+ <Switch>
367
+ <Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
368
+ <Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
369
+ </Switch>
370
+ </Button>
371
+ </Show>
372
+ {props.actions}
373
+ </div>
374
+ </div>
375
+
376
+ <ScrollView
377
+ data-slot="session-review-scroll"
378
+ viewportRef={(el) => {
379
+ scroll = el
380
+ props.scrollRef?.(el)
381
+ queue()
382
+ }}
383
+ onScroll={handleScroll}
384
+ classList={{
385
+ [props.classes?.root ?? ""]: !!props.classes?.root,
386
+ }}
387
+ >
388
+ <div data-slot="session-review-container" class={props.classes?.container}>
389
+ <Show when={hasDiffs()} fallback={props.empty}>
390
+ <div data-slot="session-review-list" class="pb-6">
391
+ <Accordion multiple value={open()} onChange={handleChange}>
392
+ <For each={files()}>
393
+ {(file) => {
394
+ const diff = () => itemsMap()[file]
395
+
396
+ // binary files have empty diffs that we can't render
397
+ const diffCanRender = () => diff().additions !== 0 || diff().deletions !== 0
398
+
399
+ const expanded = createMemo(() => open().includes(file))
400
+ const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
401
+ const force = () => !!store.force[file]
402
+
403
+ const comments = createMemo(() => grouped().get(file) ?? [])
404
+ const commentedLines = createMemo(() => comments().map((c) => c.selection))
405
+
406
+ const beforeText = () => text(diff(), "deletions")
407
+ const afterText = () => text(diff(), "additions")
408
+ const changedLines = () => diff().additions + diff().deletions
409
+ const mediaKind = createMemo(() => mediaKindFromPath(file))
410
+
411
+ const tooLarge = createMemo(() => {
412
+ if (!expanded()) return false
413
+ if (force()) return false
414
+ if (mediaKind()) return false
415
+ return changedLines() > MAX_DIFF_CHANGED_LINES
416
+ })
417
+
418
+ const isAdded = () =>
419
+ diff().status === "added" || (beforeText().length === 0 && afterText().length > 0)
420
+ const isDeleted = () =>
421
+ diff().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
422
+
423
+ const selectedLines = createMemo(() => {
424
+ const current = selection()
425
+ if (!current || current.file !== file) return null
426
+ return current.range
427
+ })
428
+
429
+ const draftRange = createMemo(() => {
430
+ const current = commenting()
431
+ if (!current || current.file !== file) return null
432
+ return current.range
433
+ })
434
+
435
+ const commentsUi = createLineCommentController<SessionReviewComment>({
436
+ comments,
437
+ label: i18n.t("ui.lineComment.submit"),
438
+ draftKey: () => file,
439
+ mention: props.lineCommentMention,
440
+ state: {
441
+ opened: () => {
442
+ const current = opened()
443
+ if (!current || current.file !== file) return null
444
+ return current.id
445
+ },
446
+ setOpened: (id) => setStore("opened", id ? { file, id } : null),
447
+ selected: selectedLines,
448
+ setSelected: (range) => setStore("selection", range ? { file, range } : null),
449
+ commenting: draftRange,
450
+ setCommenting: (range) => setStore("commenting", range ? { file, range } : null),
451
+ },
452
+ getSide: selectionSide,
453
+ clearSelectionOnSelectionEndNull: false,
454
+ onSubmit: ({ comment, selection }) => {
455
+ props.onLineComment?.({
456
+ file,
457
+ selection,
458
+ comment,
459
+ preview: selectionPreview(diff(), selection),
460
+ })
461
+ },
462
+ onUpdate: ({ id, comment, selection }) => {
463
+ props.onLineCommentUpdate?.({
464
+ id,
465
+ file,
466
+ selection,
467
+ comment,
468
+ preview: selectionPreview(diff(), selection),
469
+ })
470
+ },
471
+ onDelete: (comment) => {
472
+ props.onLineCommentDelete?.({
473
+ id: comment.id,
474
+ file,
475
+ })
476
+ },
477
+ editSubmitLabel: props.lineCommentActions?.saveLabel,
478
+ renderCommentActions: props.lineCommentActions
479
+ ? (comment, controls) => (
480
+ <ReviewCommentMenu
481
+ labels={props.lineCommentActions!}
482
+ onEdit={controls.edit}
483
+ onDelete={controls.remove}
484
+ />
485
+ )
486
+ : undefined,
487
+ })
488
+
489
+ onCleanup(() => {
490
+ anchors.delete(file)
491
+ nodes.delete(file)
492
+ queue()
493
+ })
494
+
495
+ const handleLineSelected = (range: SelectedLineRange | null) => {
496
+ if (!props.onLineComment) return
497
+ commentsUi.onLineSelected(range)
498
+ }
499
+
500
+ const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
501
+ if (!props.onLineComment) return
502
+ commentsUi.onLineSelectionEnd(range)
503
+ }
504
+
505
+ return (
506
+ <Accordion.Item
507
+ value={diffCanRender() ? file : null!}
508
+ id={diffId(file)}
509
+ data-file={file}
510
+ data-slot="session-review-accordion-item"
511
+ data-selected={props.focusedFile === file ? "" : undefined}
512
+ >
513
+ <StickyAccordionHeader>
514
+ <Accordion.Trigger disabled={!diffCanRender()} class="cursor-default">
515
+ <div data-slot="session-review-trigger-content">
516
+ <div data-slot="session-review-file-info">
517
+ <FileIcon node={{ path: file, type: "file" }} />
518
+ <div data-slot="session-review-file-name-container">
519
+ <Show when={file.includes("/")}>
520
+ <span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
521
+ </Show>
522
+ <span data-slot="session-review-filename">{getFilename(file)}</span>
523
+ <Show when={props.onViewFile && diffCanRender()}>
524
+ <Tooltip value={openFileLabel()} placement="top" gutter={4}>
525
+ <button
526
+ data-slot="session-review-view-button"
527
+ type="button"
528
+ aria-label={openFileLabel()}
529
+ onClick={(e) => {
530
+ e.stopPropagation()
531
+ props.onViewFile?.(file)
532
+ }}
533
+ >
534
+ <Icon name="open-file" size="small" />
535
+ </button>
536
+ </Tooltip>
537
+ </Show>
538
+ </div>
539
+ </div>
540
+ <div data-slot="session-review-trigger-actions">
541
+ <Switch>
542
+ <Match when={isAdded()}>
543
+ <div data-slot="session-review-change-group" data-type="added">
544
+ <span data-slot="session-review-change" data-type="added">
545
+ {i18n.t("ui.sessionReview.change.added")}
546
+ </span>
547
+ <DiffChanges changes={diff()} />
548
+ </div>
549
+ </Match>
550
+ <Match when={isDeleted()}>
551
+ <span data-slot="session-review-change" data-type="removed">
552
+ {i18n.t("ui.sessionReview.change.removed")}
553
+ </span>
554
+ </Match>
555
+ <Match when={!!mediaKind()}>
556
+ <span data-slot="session-review-change" data-type="modified">
557
+ {i18n.t("ui.sessionReview.change.modified")}
558
+ </span>
559
+ </Match>
560
+ <Match when={true}>
561
+ <DiffChanges changes={diff()} />
562
+ </Match>
563
+ </Switch>
564
+ <Show when={diffCanRender()}>
565
+ <span data-slot="session-review-diff-chevron">
566
+ <Icon name="chevron-down" size="small" />
567
+ </span>
568
+ </Show>
569
+ </div>
570
+ </div>
571
+ </Accordion.Trigger>
572
+ </StickyAccordionHeader>
573
+ <Accordion.Content data-slot="session-review-accordion-content">
574
+ <div
575
+ data-slot="session-review-diff-wrapper"
576
+ ref={(el) => {
577
+ anchors.set(file, el)
578
+ nodes.set(file, el)
579
+ queue()
580
+ }}
581
+ >
582
+ <Show when={expanded()}>
583
+ <Switch>
584
+ <Match when={!mounted() && !tooLarge()}>
585
+ <div
586
+ data-slot="session-review-diff-placeholder"
587
+ class="rounded-lg border border-border-weak-base bg-background-stronger/40"
588
+ style={{ height: "160px" }}
589
+ />
590
+ </Match>
591
+ <Match when={tooLarge()}>
592
+ <div data-slot="session-review-large-diff">
593
+ <div data-slot="session-review-large-diff-title">
594
+ {i18n.t("ui.sessionReview.largeDiff.title")}
595
+ </div>
596
+ <div data-slot="session-review-large-diff-meta">
597
+ {i18n.t("ui.sessionReview.largeDiff.meta", {
598
+ limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
599
+ current: changedLines().toLocaleString(),
600
+ })}
601
+ </div>
602
+ <div data-slot="session-review-large-diff-actions">
603
+ <Button
604
+ size="normal"
605
+ variant="secondary"
606
+ onClick={() => setStore("force", file, true)}
607
+ >
608
+ {i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
609
+ </Button>
610
+ </div>
611
+ </div>
612
+ </Match>
613
+ <Match when={true}>
614
+ <Dynamic
615
+ component={fileComponent}
616
+ mode="diff"
617
+ fileDiff={diff().fileDiff}
618
+ preloadedDiff={diff().preloaded}
619
+ diffStyle={diffStyle()}
620
+ onRendered={() => {
621
+ props.onDiffRendered?.()
622
+ }}
623
+ enableLineSelection={props.onLineComment != null}
624
+ enableGutterUtility={props.onLineComment != null}
625
+ onLineSelected={handleLineSelected}
626
+ onLineSelectionEnd={handleLineSelectionEnd}
627
+ annotations={commentsUi.annotations()}
628
+ renderAnnotation={commentsUi.renderAnnotation}
629
+ renderGutterUtility={
630
+ props.onLineComment ? commentsUi.renderGutterUtility : undefined
631
+ }
632
+ selectedLines={selectedLines()}
633
+ commentedLines={commentedLines()}
634
+ media={{
635
+ mode: "auto",
636
+ path: file,
637
+ deleted: diff().status === "deleted",
638
+ readFile: diff().status === "deleted" ? undefined : props.readFile,
639
+ }}
640
+ />
641
+ </Match>
642
+ </Switch>
643
+ </Show>
644
+ </div>
645
+ </Accordion.Content>
646
+ </Accordion.Item>
647
+ )
648
+ }}
649
+ </For>
650
+ </Accordion>
651
+ </div>
652
+ </Show>
653
+ </div>
654
+ </ScrollView>
655
+ </div>
656
+ )
657
+ }