@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,186 @@
1
+ import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
2
+ import { ComponentProps } from "solid-js"
3
+ import { lineCommentStyles } from "../components/line-comment-styles"
4
+
5
+ export type DiffProps<T = {}> = FileDiffOptions<T> & {
6
+ before: FileContents
7
+ after: FileContents
8
+ annotations?: DiffLineAnnotation<T>[]
9
+ selectedLines?: SelectedLineRange | null
10
+ commentedLines?: SelectedLineRange[]
11
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
12
+ onRendered?: () => void
13
+ class?: string
14
+ classList?: ComponentProps<"div">["classList"]
15
+ }
16
+
17
+ const unsafeCSS = `
18
+ :host {
19
+ --diffs-bg: var(--opencode-diffs-bg, var(--color-background-stronger));
20
+ }
21
+
22
+ [data-diff],
23
+ [data-file] {
24
+ /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
25
+ --diffs-bg-deletion-override: light-dark(
26
+ color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
27
+ color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
28
+ );
29
+ --diffs-bg-addition-override: light-dark(
30
+ color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
31
+ color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
32
+ );
33
+ --diffs-selection-base: var(--surface-warning-strong);
34
+ --diffs-selection-border: var(--border-warning-base);
35
+ --diffs-selection-number-fg: #1c1917;
36
+ /* Use explicit alpha instead of color-mix(..., transparent) to avoid Safari's non-premultiplied interpolation bugs. */
37
+ --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--surface-warning-base) r g b / 0.65));
38
+ --diffs-bg-selection-number: var(
39
+ --diffs-bg-selection-number-override,
40
+ rgb(from var(--surface-warning-base) r g b / 0.85)
41
+ );
42
+ --diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
43
+ }
44
+
45
+ :host([data-color-scheme='dark']) [data-diff],
46
+ :host([data-color-scheme='dark']) [data-file] {
47
+ --diffs-selection-number-fg: #fdfbfb;
48
+ --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
49
+ --diffs-bg-selection-number: var(
50
+ --diffs-bg-selection-number-override,
51
+ rgb(from var(--solaris-dark-6) r g b / 0.85)
52
+ );
53
+ }
54
+
55
+ [data-diff] ::selection,
56
+ [data-file] ::selection {
57
+ background-color: var(--diffs-bg-selection-text);
58
+ }
59
+
60
+ ::highlight(opencode-find) {
61
+ background-color: rgb(from var(--surface-warning-base) r g b / 0.35);
62
+ }
63
+
64
+ ::highlight(opencode-find-current) {
65
+ background-color: rgb(from var(--surface-warning-strong) r g b / 0.55);
66
+ }
67
+
68
+ [data-diff] [data-line][data-comment-selected]:not([data-selected-line]) {
69
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
70
+ }
71
+
72
+ [data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
73
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
74
+ }
75
+
76
+ [data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
77
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
78
+ color: var(--diffs-selection-number-fg);
79
+ }
80
+
81
+ [data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
82
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
83
+ color: var(--diffs-selection-number-fg);
84
+ }
85
+
86
+ [data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
87
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
88
+ }
89
+
90
+ [data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
91
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
92
+ }
93
+
94
+ [data-diff] [data-line][data-selected-line] {
95
+ background-color: var(--diffs-bg-selection);
96
+ box-shadow: inset 2px 0 0 var(--diffs-selection-border);
97
+ }
98
+
99
+ [data-file] [data-line][data-selected-line] {
100
+ background-color: var(--diffs-bg-selection);
101
+ box-shadow: inset 2px 0 0 var(--diffs-selection-border);
102
+ }
103
+
104
+ [data-diff] [data-column-number][data-selected-line] {
105
+ background-color: var(--diffs-bg-selection-number);
106
+ color: var(--diffs-selection-number-fg);
107
+ }
108
+
109
+ [data-file] [data-column-number][data-selected-line] {
110
+ background-color: var(--diffs-bg-selection-number);
111
+ color: var(--diffs-selection-number-fg);
112
+ }
113
+
114
+ [data-diff] [data-column-number][data-line-type='context'][data-selected-line],
115
+ [data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
116
+ [data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
117
+ [data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] {
118
+ color: var(--diffs-selection-number-fg);
119
+ }
120
+
121
+ /* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */
122
+ [data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] {
123
+ --diffs-bg-deletion-emphasis: light-dark(
124
+ rgb(from var(--diffs-deletion-base) r g b / 0.07),
125
+ rgb(from var(--diffs-deletion-base) r g b / 0.1)
126
+ );
127
+ }
128
+
129
+ [data-diff-header],
130
+ [data-diff],
131
+ [data-file] {
132
+ [data-separator] {
133
+ height: 24px;
134
+ }
135
+ [data-column-number] {
136
+ background-color: var(--diffs-bg);
137
+ cursor: default !important;
138
+ }
139
+
140
+ &[data-interactive-line-numbers] [data-column-number] {
141
+ cursor: default !important;
142
+ }
143
+
144
+ &[data-interactive-lines] [data-line] {
145
+ cursor: auto !important;
146
+ }
147
+ [data-code] {
148
+ overflow-x: auto !important;
149
+ overflow-y: clip !important;
150
+ }
151
+ }
152
+
153
+ ${lineCommentStyles}
154
+
155
+ `
156
+
157
+ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
158
+ return {
159
+ theme: "OpenCode",
160
+ themeType: "system",
161
+ disableLineNumbers: false,
162
+ overflow: "wrap",
163
+ diffStyle: style ?? "unified",
164
+ diffIndicators: "bars",
165
+ lineHoverHighlight: "both",
166
+ disableBackground: false,
167
+ expansionLineCount: 20,
168
+ hunkSeparators: "line-info-basic",
169
+ lineDiffType: style === "split" ? "word-alt" : "none",
170
+ maxLineDiffLength: 1000,
171
+ maxLineLengthForHighlighting: 1000,
172
+ disableFileHeader: true,
173
+ unsafeCSS,
174
+ } as const
175
+ }
176
+
177
+ export const styleVariables = {
178
+ "--diffs-font-family": "var(--font-family-mono)",
179
+ "--diffs-font-size": "var(--font-size-small)",
180
+ "--diffs-line-height": "24px",
181
+ "--diffs-tab-size": 2,
182
+ "--diffs-font-features": "var(--font-family-mono--font-feature-settings)",
183
+ "--diffs-header-font-family": "var(--font-family-sans)",
184
+ "--diffs-gap-block": 0,
185
+ "--diffs-min-number-column-width": "4ch",
186
+ }
@@ -0,0 +1,110 @@
1
+ import type { FileContent } from "@neurocode-ai/sdk/v2"
2
+
3
+ export type MediaKind = "image" | "audio" | "svg"
4
+
5
+ const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
6
+ const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
7
+
8
+ type MediaValue = unknown
9
+
10
+ function mediaRecord(value: unknown) {
11
+ if (!value || typeof value !== "object") return
12
+ return value as Partial<FileContent> & {
13
+ content?: unknown
14
+ encoding?: unknown
15
+ mimeType?: unknown
16
+ type?: unknown
17
+ }
18
+ }
19
+
20
+ export function normalizeMimeType(type: string | undefined) {
21
+ if (!type) return
22
+ const mime = type.split(";", 1)[0]?.trim().toLowerCase()
23
+ if (!mime) return
24
+ if (mime === "audio/x-aac") return "audio/aac"
25
+ if (mime === "audio/x-m4a") return "audio/mp4"
26
+ return mime
27
+ }
28
+
29
+ export function fileExtension(path: string | undefined) {
30
+ if (!path) return ""
31
+ const idx = path.lastIndexOf(".")
32
+ if (idx === -1) return ""
33
+ return path.slice(idx + 1).toLowerCase()
34
+ }
35
+
36
+ export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
37
+ const ext = fileExtension(path)
38
+ if (ext === "svg") return "svg"
39
+ if (imageExtensions.has(ext)) return "image"
40
+ if (audioExtensions.has(ext)) return "audio"
41
+ }
42
+
43
+ export function isBinaryContent(value: MediaValue) {
44
+ return mediaRecord(value)?.type === "binary"
45
+ }
46
+
47
+ function validDataUrl(value: string, kind: MediaKind) {
48
+ if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
49
+ if (kind === "image") return value.startsWith("data:image/") ? value : undefined
50
+ if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
51
+ if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
52
+ if (value.startsWith("data:audio/")) return value
53
+ }
54
+
55
+ export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
56
+ if (!value) return
57
+
58
+ if (typeof value === "string") {
59
+ return validDataUrl(value, kind)
60
+ }
61
+
62
+ const record = mediaRecord(value)
63
+ if (!record) return
64
+
65
+ if (typeof record.content !== "string") return
66
+
67
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
68
+ if (!mime) return
69
+
70
+ if (kind === "svg") {
71
+ if (mime !== "image/svg+xml") return
72
+ if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
73
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
74
+ }
75
+
76
+ if (kind === "image" && !mime.startsWith("image/")) return
77
+ if (kind === "audio" && !mime.startsWith("audio/")) return
78
+ if (record.encoding !== "base64") return
79
+
80
+ return `data:${mime};base64,${record.content}`
81
+ }
82
+
83
+ function decodeBase64Utf8(value: string) {
84
+ if (typeof atob !== "function") return
85
+
86
+ try {
87
+ const raw = atob(value)
88
+ const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
89
+ if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
90
+ return raw
91
+ } catch {}
92
+ }
93
+
94
+ export function svgTextFromValue(value: MediaValue) {
95
+ const record = mediaRecord(value)
96
+ if (!record) return
97
+ if (typeof record.content !== "string") return
98
+
99
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
100
+ if (mime !== "image/svg+xml") return
101
+ if (record.encoding === "base64") return decodeBase64Utf8(record.content)
102
+ return record.content
103
+ }
104
+
105
+ export function hasMediaValue(value: MediaValue) {
106
+ if (typeof value === "string") return value.length > 0
107
+ const record = mediaRecord(value)
108
+ if (!record) return false
109
+ return typeof record.content === "string" && record.content.length > 0
110
+ }
@@ -0,0 +1,132 @@
1
+ import { type SelectedLineRange } from "@pierre/diffs"
2
+
3
+ type SelectionKey = "ui.sessionReview.selection.line" | "ui.sessionReview.selection.lines"
4
+ type SelectionVars = Record<string, string | number>
5
+
6
+ type PointerMode = "none" | "text" | "numbers"
7
+ type Side = SelectedLineRange["side"]
8
+ type LineSpan = Pick<SelectedLineRange, "start" | "end">
9
+
10
+ export function formatSelectedLineLabel(range: LineSpan, t: (key: SelectionKey, params: SelectionVars) => string) {
11
+ const start = Math.min(range.start, range.end)
12
+ const end = Math.max(range.start, range.end)
13
+ if (start === end) return t("ui.sessionReview.selection.line", { line: start })
14
+ return t("ui.sessionReview.selection.lines", { start, end })
15
+ }
16
+
17
+ export function previewSelectedLines(source: string, range: LineSpan) {
18
+ const start = Math.max(1, Math.min(range.start, range.end))
19
+ const end = Math.max(range.start, range.end)
20
+ const lines = source.split("\n").slice(start - 1, end)
21
+ if (lines.length === 0) return
22
+ return lines.slice(0, 2).join("\n")
23
+ }
24
+
25
+ export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
26
+ const next: SelectedLineRange = {
27
+ start: range.start,
28
+ end: range.end,
29
+ }
30
+
31
+ if (range.side) next.side = range.side
32
+ if (range.endSide) next.endSide = range.endSide
33
+ return next
34
+ }
35
+
36
+ export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
37
+ if (!range) return false
38
+
39
+ const start = Math.min(range.start, range.end)
40
+ const end = Math.max(range.start, range.end)
41
+ if (line < start || line > end) return false
42
+ if (!side) return true
43
+
44
+ const first = range.side
45
+ const last = range.endSide ?? first
46
+ if (!first && !last) return true
47
+ if (!first || !last) return (first ?? last) === side
48
+ if (first === last) return first === side
49
+ if (line === start) return first === side
50
+ if (line === end) return last === side
51
+ return true
52
+ }
53
+
54
+ export function isSingleLineSelection(range: SelectedLineRange | null) {
55
+ if (!range) return false
56
+ return range.start === range.end && (range.endSide == null || range.endSide === range.side)
57
+ }
58
+
59
+ export function toRange(source: Range | StaticRange): Range {
60
+ if (source instanceof Range) return source
61
+ const range = new Range()
62
+ range.setStart(source.startContainer, source.startOffset)
63
+ range.setEnd(source.endContainer, source.endOffset)
64
+ return range
65
+ }
66
+
67
+ export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
68
+ if (!root || !range) return
69
+
70
+ requestAnimationFrame(() => {
71
+ const selection =
72
+ (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
73
+ if (!selection) return
74
+
75
+ try {
76
+ selection.removeAllRanges()
77
+ selection.addRange(range)
78
+ } catch {}
79
+ })
80
+ }
81
+
82
+ export function createLineNumberSelectionBridge() {
83
+ let mode: PointerMode = "none"
84
+ let line: number | undefined
85
+ let moved = false
86
+ let pending = false
87
+
88
+ const clear = () => {
89
+ mode = "none"
90
+ line = undefined
91
+ moved = false
92
+ }
93
+
94
+ return {
95
+ begin(numberColumn: boolean, next: number | undefined) {
96
+ if (!numberColumn) {
97
+ mode = "text"
98
+ return
99
+ }
100
+
101
+ mode = "numbers"
102
+ line = next
103
+ moved = false
104
+ },
105
+ track(buttons: number, next: number | undefined) {
106
+ if (mode !== "numbers") return false
107
+
108
+ if ((buttons & 1) === 0) {
109
+ clear()
110
+ return true
111
+ }
112
+
113
+ if (next !== undefined && line !== undefined && next !== line) moved = true
114
+ return true
115
+ },
116
+ finish() {
117
+ const current = mode
118
+ pending = current === "numbers" && moved
119
+ clear()
120
+ return current
121
+ },
122
+ consume(range: SelectedLineRange | null) {
123
+ const result = pending && !isSingleLineSelection(range)
124
+ pending = false
125
+ return result
126
+ },
127
+ reset() {
128
+ pending = false
129
+ clear()
130
+ },
131
+ }
132
+ }
@@ -0,0 +1,100 @@
1
+ import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
2
+
3
+ type Target = {
4
+ key: Document | HTMLElement
5
+ root: Document | HTMLElement
6
+ content: HTMLElement | undefined
7
+ }
8
+
9
+ type Entry = {
10
+ virtualizer: Virtualizer
11
+ refs: number
12
+ }
13
+
14
+ const cache = new WeakMap<Document | HTMLElement, Entry>()
15
+
16
+ export const virtualMetrics: Partial<VirtualFileMetrics> = {
17
+ lineHeight: 24,
18
+ hunkSeparatorHeight: 24,
19
+ spacing: 0,
20
+ }
21
+
22
+ function scrollable(value: string) {
23
+ return value === "auto" || value === "scroll" || value === "overlay"
24
+ }
25
+
26
+ function scrollRoot(container: HTMLElement) {
27
+ let node = container.parentElement
28
+ while (node) {
29
+ const style = getComputedStyle(node)
30
+ if (scrollable(style.overflowY)) return node
31
+ node = node.parentElement
32
+ }
33
+ }
34
+
35
+ function target(container: HTMLElement): Target | undefined {
36
+ if (typeof document === "undefined") return
37
+
38
+ const review = container.closest("[data-component='session-review']")
39
+ if (review instanceof HTMLElement) {
40
+ const root = scrollRoot(container) ?? review
41
+ const content = review.querySelector("[data-slot='session-review-container']")
42
+ return {
43
+ key: review,
44
+ root,
45
+ content: content instanceof HTMLElement ? content : undefined,
46
+ }
47
+ }
48
+
49
+ const root = scrollRoot(container)
50
+ if (root) {
51
+ const content = root.querySelector("[role='log']")
52
+ return {
53
+ key: root,
54
+ root,
55
+ content: content instanceof HTMLElement ? content : undefined,
56
+ }
57
+ }
58
+
59
+ return {
60
+ key: document,
61
+ root: document,
62
+ content: undefined,
63
+ }
64
+ }
65
+
66
+ export function acquireVirtualizer(container: HTMLElement) {
67
+ const resolved = target(container)
68
+ if (!resolved) return
69
+
70
+ let entry = cache.get(resolved.key)
71
+ if (!entry) {
72
+ const virtualizer = new Virtualizer()
73
+ virtualizer.setup(resolved.root, resolved.content)
74
+ entry = {
75
+ virtualizer,
76
+ refs: 0,
77
+ }
78
+ cache.set(resolved.key, entry)
79
+ }
80
+
81
+ entry.refs += 1
82
+ let done = false
83
+
84
+ return {
85
+ virtualizer: entry.virtualizer,
86
+ release() {
87
+ if (done) return
88
+ done = true
89
+
90
+ const current = cache.get(resolved.key)
91
+ if (!current) return
92
+
93
+ current.refs -= 1
94
+ if (current.refs > 0) return
95
+
96
+ current.virtualizer.cleanUp()
97
+ cache.delete(resolved.key)
98
+ },
99
+ }
100
+ }
@@ -0,0 +1,52 @@
1
+ import { WorkerPoolManager } from "@pierre/diffs/worker"
2
+ import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
3
+
4
+ export type WorkerPoolStyle = "unified" | "split"
5
+
6
+ export function workerFactory(): Worker {
7
+ return new Worker(ShikiWorkerUrl, { type: "module" })
8
+ }
9
+
10
+ function createPool(lineDiffType: "none" | "word-alt") {
11
+ const pool = new WorkerPoolManager(
12
+ {
13
+ workerFactory,
14
+ // poolSize defaults to 8. More workers = more parallelism but
15
+ // also more memory. Too many can actually slow things down.
16
+ // NOTE: 2 is probably better for OpenCode, as I think 8 might be
17
+ // a bit overkill, especially because Safari has a significantly slower
18
+ // boot up time for workers
19
+ poolSize: 2,
20
+ },
21
+ {
22
+ theme: "OpenCode",
23
+ lineDiffType,
24
+ preferredHighlighter: "shiki-wasm",
25
+ },
26
+ )
27
+
28
+ void pool.initialize()
29
+ return pool
30
+ }
31
+
32
+ let unified: WorkerPoolManager | undefined
33
+ let split: WorkerPoolManager | undefined
34
+
35
+ export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
36
+ if (typeof window === "undefined") return
37
+
38
+ if (style === "split") {
39
+ if (!split) split = createPool("word-alt")
40
+ return split
41
+ }
42
+
43
+ if (!unified) unified = createPool("none")
44
+ return unified
45
+ }
46
+
47
+ export function getWorkerPools() {
48
+ return {
49
+ unified: getWorkerPool("unified"),
50
+ split: getWorkerPool("split"),
51
+ }
52
+ }
@@ -0,0 +1,14 @@
1
+ @layer theme, base, components, utilities;
2
+
3
+ @import "../components/basic-tool.css" layer(components);
4
+ @import "../components/file.css" layer(components);
5
+ @import "../components/markdown.css" layer(components);
6
+ @import "../components/message-part.css" layer(components);
7
+ @import "../components/message-nav.css" layer(components);
8
+ @import "../components/session-review.css" layer(components);
9
+ @import "../components/session-turn.css" layer(components);
10
+ @import "../components/shell-submessage.css" layer(components);
11
+ @import "../components/tool-count-label.css" layer(components);
12
+ @import "../components/tool-count-summary.css" layer(components);
13
+ @import "../components/tool-error-card.css" layer(components);
14
+ @import "../components/tool-status-title.css" layer(components);
@@ -0,0 +1,70 @@
1
+ [data-component="attachment-card-v2"] {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 6px;
5
+ box-sizing: border-box;
6
+ width: 160px;
7
+ min-width: 160px;
8
+ max-width: 160px;
9
+ padding: 8px;
10
+ border-radius: 6px;
11
+ background: var(--v2-overlay-simple-overlay-hover);
12
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
13
+ cursor: default;
14
+
15
+ &[data-wide] {
16
+ width: 100%;
17
+ min-width: 0;
18
+ max-width: none;
19
+ }
20
+
21
+ &[data-surface="base"] {
22
+ background: var(--v2-background-bg-base);
23
+ }
24
+
25
+ &[data-active] {
26
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
27
+ }
28
+
29
+ &[data-clickable] {
30
+ cursor: pointer;
31
+ }
32
+
33
+ [data-slot="attachment-card-v2-title"],
34
+ [data-slot="attachment-card-v2-subtitle"] {
35
+ max-width: 100%;
36
+ font-family: var(--v2-font-family-sans, "Inter", sans-serif);
37
+ font-style: normal;
38
+ font-size: 11px;
39
+ line-height: 12px;
40
+ letter-spacing: 0.05px;
41
+ font-variation-settings: "slnt" 0;
42
+ }
43
+
44
+ [data-slot="attachment-card-v2-title"] {
45
+ width: 100%;
46
+ min-width: 0;
47
+ overflow: hidden;
48
+ text-overflow: ellipsis;
49
+ white-space: nowrap;
50
+ font-weight: 530;
51
+ color: var(--v2-text-text-base);
52
+ }
53
+
54
+ [data-slot="attachment-card-v2-subtitle"] {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 4px;
58
+ min-width: 0;
59
+ overflow: hidden;
60
+ white-space: nowrap;
61
+ font-weight: 440;
62
+ color: var(--v2-text-text-muted);
63
+
64
+ [data-component="file-icon"] {
65
+ width: 12px;
66
+ height: 12px;
67
+ flex: none;
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,33 @@
1
+ import type { JSX } from "solid-js"
2
+ import "./attachment-card-v2.css"
3
+
4
+ /** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
5
+ export function AttachmentCardV2(props: {
6
+ title: string
7
+ active?: boolean
8
+ clickable?: boolean
9
+ wide?: boolean
10
+ surface?: "base"
11
+ /** native title attribute */
12
+ hover?: string
13
+ titleRef?: (element: HTMLSpanElement) => void
14
+ onClick?: () => void
15
+ children: JSX.Element
16
+ }) {
17
+ return (
18
+ <div
19
+ data-component="attachment-card-v2"
20
+ data-active={props.active ? "true" : undefined}
21
+ data-clickable={props.clickable ? "true" : undefined}
22
+ data-wide={props.wide ? "true" : undefined}
23
+ data-surface={props.surface}
24
+ title={props.hover}
25
+ onClick={() => props.onClick?.()}
26
+ >
27
+ <span ref={(element) => props.titleRef?.(element)} data-slot="attachment-card-v2-title">
28
+ {props.title}
29
+ </span>
30
+ <span data-slot="attachment-card-v2-subtitle">{props.children}</span>
31
+ </div>
32
+ )
33
+ }