@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,706 @@
1
+ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
2
+ import { FileIcon } from "@neurocode-ai/ui/file-icon"
3
+ import { Icon } from "@neurocode-ai/ui/icon"
4
+ import { IconButton } from "@neurocode-ai/ui/icon-button"
5
+ import { ProviderIcon } from "@neurocode-ai/ui/provider-icon"
6
+ import { ButtonV2 } from "@neurocode-ai/ui/v2/button-v2"
7
+ import { Icon as IconV2 } from "@neurocode-ai/ui/v2/icon"
8
+ import { IconButtonV2 } from "@neurocode-ai/ui/v2/icon-button-v2"
9
+ import { KeybindV2 } from "@neurocode-ai/ui/v2/keybind-v2"
10
+ import { MenuV2 } from "@neurocode-ai/ui/v2/menu-v2"
11
+ import { TooltipV2 } from "@neurocode-ai/ui/v2/tooltip-v2"
12
+ import { AttachmentCardV2 } from "../attachment-card-v2"
13
+ import { CommentCardV2 } from "../comment-card-v2"
14
+ import { typeLabel } from "../../../components/message-file"
15
+ import type {
16
+ PromptInputV2Attachment,
17
+ PromptInputV2Comment,
18
+ PromptInputV2Option,
19
+ PromptInputV2PersistedState,
20
+ PromptInputV2Prompt,
21
+ PromptInputV2Suggestion,
22
+ } from "./types"
23
+ import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
24
+
25
+ export type {
26
+ PromptInputV2Attachment,
27
+ PromptInputV2Comment,
28
+ PromptInputV2Option,
29
+ PromptInputV2PersistedState,
30
+ PromptInputV2Suggestion,
31
+ } from "./types"
32
+
33
+ export type PromptInputV2Mode = "normal" | "shell"
34
+
35
+ export type PromptInputV2Props = {
36
+ controller: PromptInputV2Interaction
37
+ disabled?: boolean
38
+ readOnly?: boolean
39
+ borderUnderlay?: boolean
40
+ class?: string
41
+ modelControl?: JSX.Element
42
+ attachKeybind?: string[]
43
+ attachShortcut?: string
44
+ }
45
+
46
+ export function PromptInputV2(props: PromptInputV2Props) {
47
+ const state = props.controller.state
48
+ const view = props.controller.view
49
+ let editor: HTMLDivElement | undefined
50
+ let localInput = false
51
+ const updateCursor = () => {
52
+ if (!editor || !window.getSelection()?.isCollapsed) return
53
+ props.controller.onCursor(promptInputV2Cursor(editor))
54
+ }
55
+ const mode = createMemo(() => state.mode)
56
+ const buttons = createMemo(() => ({
57
+ opacity: mode() === "normal" ? 1 : 0,
58
+ "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const),
59
+ transition: "opacity 200ms ease",
60
+ }))
61
+
62
+ createEffect(() => {
63
+ const parts = props.controller.parts()
64
+ if (!editor) return
65
+ if (localInput) {
66
+ localInput = false
67
+ return
68
+ }
69
+ renderPromptInputV2Editor(editor, parts)
70
+ })
71
+
72
+ return (
73
+ <div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
74
+ <input
75
+ ref={props.controller.setFileInput}
76
+ type="file"
77
+ multiple
78
+ accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,application/json,application/ld+json,application/toml,application/x-toml,application/x-yaml,application/xml,application/yaml,.c,.cc,.cjs,.conf,.cpp,.css,.csv,.cts,.env,.go,.gql,.graphql,.h,.hh,.hpp,.htm,.html,.ini,.java,.js,.json,.jsx,.log,.md,.mdx,.mjs,.mts,.py,.rb,.rs,.sass,.scss,.sh,.sql,.toml,.ts,.tsx,.txt,.xml,.yaml,.yml,.zsh"
79
+ class="hidden"
80
+ onChange={(event) => {
81
+ const list = event.currentTarget.files
82
+ if (list) props.controller.addAttachments(Array.from(list))
83
+ event.currentTarget.value = ""
84
+ }}
85
+ />
86
+ <Show when={state.popover.type !== "closed"}>
87
+ <PromptInputV2Popover
88
+ emptyLabel="No matching items"
89
+ items={props.controller.suggestions()}
90
+ activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
91
+ search={
92
+ state.popover.type === "command-menu"
93
+ ? {
94
+ value: state.popover.query,
95
+ label: "Commands",
96
+ placeholder: "/",
97
+ onValueChange: props.controller.setQuery,
98
+ onKeyDown: props.controller.onKeyDown,
99
+ }
100
+ : undefined
101
+ }
102
+ onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
103
+ onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
104
+ />
105
+ </Show>
106
+ <form
107
+ data-component="prompt-input-v2"
108
+ data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
109
+ class="group/prompt-input relative min-h-[96px] w-full rounded-xl bg-v2-background-bg-base"
110
+ classList={{
111
+ "shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
112
+ "border border-v2-icon-icon-info border-dashed": state.drag === "active",
113
+ }}
114
+ onSubmit={(event) => {
115
+ event.preventDefault()
116
+ if (!props.disabled) props.controller.submit()
117
+ }}
118
+ onDragEnter={props.controller.onDragEnter}
119
+ onDragOver={props.controller.onDragOver}
120
+ onDragLeave={props.controller.onDragLeave}
121
+ onDrop={props.controller.onDrop}
122
+ >
123
+ <Show when={state.drag === "active"}>
124
+ <div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
125
+ Drop files to attach
126
+ </div>
127
+ </Show>
128
+
129
+ <Show when={state.mode === "normal"}>
130
+ <PromptInputV2Attachments
131
+ attachments={props.controller.attachments()}
132
+ comments={props.controller.comments()}
133
+ activeCommentID={state.activeContextID}
134
+ removeLabel="Remove attachment"
135
+ onAttachmentClick={props.controller.openAttachment}
136
+ onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
137
+ onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
138
+ onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
139
+ />
140
+ </Show>
141
+
142
+ <div class="relative min-h-[60px]">
143
+ <div
144
+ ref={(element) => {
145
+ editor = element
146
+ props.controller.setEditor(element)
147
+ renderPromptInputV2Editor(element, props.controller.parts())
148
+ }}
149
+ data-component="prompt-input"
150
+ role="textbox"
151
+ aria-multiline="true"
152
+ aria-label="Prompt"
153
+ contenteditable={!props.disabled && !props.readOnly}
154
+ autocapitalize={state.mode === "normal" ? "sentences" : "off"}
155
+ autocorrect={state.mode === "normal" ? "on" : "off"}
156
+ spellcheck={state.mode === "normal"}
157
+ // @ts-expect-error
158
+ autocomplete="off"
159
+ class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
160
+ classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
161
+ onInput={(event) => {
162
+ const cursor = promptInputV2Cursor(event.currentTarget)
163
+ const prompt = parsePromptInputV2Editor(event.currentTarget)
164
+ const images = props.controller.parts().filter((part) => part.type === "image")
165
+ localInput = true
166
+ props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
167
+ }}
168
+ onKeyDown={(event) => {
169
+ if (props.controller.onKeyDown(event)) return
170
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
171
+ event.preventDefault()
172
+ if (event.repeat) return
173
+ props.controller.submit()
174
+ }
175
+ }}
176
+ onKeyUp={updateCursor}
177
+ onPointerUp={updateCursor}
178
+ onPaste={props.controller.onPaste}
179
+ onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
180
+ />
181
+ <Show when={!props.controller.value()}>
182
+ <div
183
+ class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
184
+ classList={{ "font-mono!": state.mode === "shell" }}
185
+ >
186
+ {view.placeholder?.() ??
187
+ (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")}
188
+ </div>
189
+ </Show>
190
+ </div>
191
+
192
+ <div class="flex h-11 items-center px-2">
193
+ <div
194
+ class="flex min-w-0 flex-1 items-center gap-1"
195
+ aria-hidden={state.mode === "shell"}
196
+ inert={state.mode === "shell" ? true : undefined}
197
+ style={buttons()}
198
+ >
199
+ <PromptInputV2AddMenu
200
+ disabled={state.mode === "shell"}
201
+ title="Add images and files"
202
+ keybind={props.attachKeybind ?? ["Mod", "U"]}
203
+ attachLabel="Images and files"
204
+ attachShortcut={props.attachShortcut ?? "Mod+U"}
205
+ commandsLabel="Commands"
206
+ contextLabel="Context"
207
+ shellLabel="Shell command"
208
+ onAttach={props.controller.attach}
209
+ onCommands={props.controller.openCommands}
210
+ onContext={props.controller.openContext}
211
+ onShell={props.controller.openShell}
212
+ />
213
+ <Show when={view.agent}>
214
+ {(control) => (
215
+ <PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control()} />
216
+ )}
217
+ </Show>
218
+ <Show
219
+ when={props.modelControl}
220
+ fallback={
221
+ <Show when={view.model}>
222
+ {(control) => (
223
+ <PromptInputV2ConfiguredSelect
224
+ title="Choose model"
225
+ keybind={["Mod", "M"]}
226
+ control={control()}
227
+ model
228
+ />
229
+ )}
230
+ </Show>
231
+ }
232
+ >
233
+ {props.modelControl}
234
+ </Show>
235
+ <Show when={view.variant}>
236
+ {(control) => (
237
+ <Show when={control().options().length > 1}>
238
+ <PromptInputV2ConfiguredSelect
239
+ title="Choose model variant"
240
+ keybind={["Shift", "Mod", "D"]}
241
+ control={control()}
242
+ />
243
+ </Show>
244
+ )}
245
+ </Show>
246
+ </div>
247
+ <PromptInputV2SubmitButton
248
+ mode={state.mode}
249
+ stopping={view.submit.stopping()}
250
+ disabled={!props.controller.canSubmit()}
251
+ sendLabel="Send"
252
+ stopLabel="Stop"
253
+ onSubmit={props.controller.submit}
254
+ onStop={props.controller.stop}
255
+ />
256
+ </div>
257
+ </form>
258
+ </div>
259
+ )
260
+ }
261
+
262
+ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) {
263
+ const active = document.activeElement === editor
264
+ editor.replaceChildren(
265
+ ...prompt.flatMap<Node>((part) => {
266
+ if (part.type === "image") return []
267
+ if (part.type === "text") return [document.createTextNode(part.content)]
268
+ const mention = document.createElement("span")
269
+ mention.textContent = part.content
270
+ mention.contentEditable = "false"
271
+ mention.dataset.mention =
272
+ part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
273
+ if (part.type === "agent") mention.dataset.name = part.name
274
+ if (part.type === "file") {
275
+ mention.dataset.path = part.path
276
+ if (part.mime) mention.dataset.mime = part.mime
277
+ if (part.filename) mention.dataset.filename = part.filename
278
+ }
279
+ return [mention]
280
+ }),
281
+ )
282
+ if (!active) return
283
+ const selection = window.getSelection()
284
+ const range = document.createRange()
285
+ range.selectNodeContents(editor)
286
+ range.collapse(false)
287
+ selection?.removeAllRanges()
288
+ selection?.addRange(range)
289
+ }
290
+
291
+ function parsePromptInputV2Editor(editor: HTMLDivElement) {
292
+ const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
293
+ let buffer = ""
294
+ let position = 0
295
+
296
+ const flush = () => {
297
+ if (!buffer) return
298
+ parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length })
299
+ position += buffer.length
300
+ buffer = ""
301
+ }
302
+ const mention = (element: HTMLElement) => {
303
+ flush()
304
+ const content = element.textContent ?? ""
305
+ if (element.dataset.mention === "agent") {
306
+ parts.push({
307
+ type: "agent",
308
+ name: element.dataset.name ?? content.slice(1),
309
+ content,
310
+ start: position,
311
+ end: position + content.length,
312
+ })
313
+ position += content.length
314
+ return
315
+ }
316
+ parts.push({
317
+ type: "file",
318
+ path: element.dataset.path ?? content.slice(1),
319
+ content,
320
+ start: position,
321
+ end: position + content.length,
322
+ ...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
323
+ ...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
324
+ })
325
+ position += content.length
326
+ }
327
+ const visit = (node: Node) => {
328
+ if (node.nodeType === Node.TEXT_NODE) {
329
+ buffer += node.textContent ?? ""
330
+ return
331
+ }
332
+ if (!(node instanceof HTMLElement)) return
333
+ if (node.dataset.mention) {
334
+ mention(node)
335
+ return
336
+ }
337
+ if (node.tagName === "BR") {
338
+ buffer += "\n"
339
+ return
340
+ }
341
+ Array.from(node.childNodes).forEach(visit)
342
+ }
343
+
344
+ Array.from(editor.childNodes).forEach((node, index, nodes) => {
345
+ visit(node)
346
+ if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
347
+ })
348
+ flush()
349
+ if (
350
+ parts.every((part) => part.type === "text") &&
351
+ parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
352
+ ) {
353
+ return [{ type: "text" as const, content: "", start: 0, end: 0 }]
354
+ }
355
+ if (parts.length > 0) return parts
356
+ return [{ type: "text" as const, content: "", start: 0, end: 0 }]
357
+ }
358
+
359
+ function promptInputV2Cursor(editor: HTMLDivElement) {
360
+ const selection = window.getSelection()
361
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
362
+ const range = selection.getRangeAt(0).cloneRange()
363
+ range.selectNodeContents(editor)
364
+ range.setEnd(selection.anchorNode!, selection.anchorOffset)
365
+ return range.toString().length
366
+ }
367
+
368
+ export function PromptInputV2Attachments(props: {
369
+ attachments: PromptInputV2Attachment[]
370
+ comments?: PromptInputV2Comment[]
371
+ activeCommentID?: string
372
+ removeLabel: string
373
+ onAttachmentClick?: (attachment: PromptInputV2Attachment) => void
374
+ onAttachmentRemove: (attachment: PromptInputV2Attachment) => void
375
+ onCommentClick?: (comment: PromptInputV2Comment) => void
376
+ onCommentRemove?: (comment: PromptInputV2Comment) => void
377
+ }) {
378
+ return (
379
+ <Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
380
+ <div data-slot="prompt-attachments" class="relative">
381
+ <div
382
+ data-slot="prompt-attachments-scroll"
383
+ class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
384
+ >
385
+ <For each={props.comments ?? []}>
386
+ {(comment) => (
387
+ <div class="relative group shrink-0">
388
+ <TooltipV2
389
+ value={comment.comment}
390
+ placement="top"
391
+ openDelay={800}
392
+ contentClass="max-w-[300px] break-words"
393
+ >
394
+ <CommentCardV2
395
+ comment={comment.comment ?? ""}
396
+ path={comment.path}
397
+ selection={comment.selection}
398
+ active={comment.key === props.activeCommentID}
399
+ onClick={() => props.onCommentClick?.(comment)}
400
+ />
401
+ </TooltipV2>
402
+ <button
403
+ type="button"
404
+ onClick={() => props.onCommentRemove?.(comment)}
405
+ class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
406
+ aria-label={props.removeLabel}
407
+ >
408
+ <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
409
+ </button>
410
+ </div>
411
+ )}
412
+ </For>
413
+ <For each={props.attachments}>
414
+ {(attachment) => (
415
+ <div class="relative group shrink-0">
416
+ <TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
417
+ <Show
418
+ when={attachment.mime.startsWith("image/")}
419
+ fallback={
420
+ <AttachmentCardV2 title={attachment.filename}>
421
+ {typeLabel(attachment.filename, attachment.mime)}
422
+ </AttachmentCardV2>
423
+ }
424
+ >
425
+ <img
426
+ src={attachment.dataUrl}
427
+ alt={attachment.filename}
428
+ class="w-[58px] h-[46px] rounded-[6px] object-cover"
429
+ onClick={() => props.onAttachmentClick?.(attachment)}
430
+ />
431
+ <div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
432
+ </Show>
433
+ </TooltipV2>
434
+ <button
435
+ type="button"
436
+ onClick={() => props.onAttachmentRemove(attachment)}
437
+ class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
438
+ aria-label={props.removeLabel}
439
+ >
440
+ <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
441
+ </button>
442
+ </div>
443
+ )}
444
+ </For>
445
+ </div>
446
+ <div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]" />
447
+ <div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]" />
448
+ </div>
449
+ </Show>
450
+ )
451
+ }
452
+
453
+ export function PromptInputV2AddMenu(props: {
454
+ disabled?: boolean
455
+ title: string
456
+ keybind?: string[]
457
+ attachLabel: string
458
+ attachShortcut?: string
459
+ commandsLabel: string
460
+ contextLabel: string
461
+ shellLabel: string
462
+ onAttach: () => void
463
+ onCommands: () => void
464
+ onContext: () => void
465
+ onShell: () => void
466
+ }) {
467
+ return (
468
+ <TooltipV2
469
+ placement="top"
470
+ value={
471
+ <>
472
+ {props.title}
473
+ <KeybindV2 keys={props.keybind ?? []} variant="neutral" />
474
+ </>
475
+ }
476
+ >
477
+ <MenuV2 gutter={6} modal={false} placement="top-start">
478
+ <MenuV2.Trigger
479
+ as={IconButtonV2}
480
+ data-action="prompt-attach"
481
+ type="button"
482
+ icon={<IconV2 name="plus" />}
483
+ variant="ghost-muted"
484
+ size="large"
485
+ disabled={props.disabled}
486
+ aria-label={props.title}
487
+ />
488
+ <MenuV2.Portal>
489
+ <MenuV2.Content style={{ "min-width": "180px" }}>
490
+ <MenuV2.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
491
+ {props.attachLabel}
492
+ </MenuV2.Item>
493
+ <MenuV2.Separator />
494
+ <MenuV2.Item onSelect={props.onCommands} shortcut="/">
495
+ {props.commandsLabel}
496
+ </MenuV2.Item>
497
+ <MenuV2.Item onSelect={props.onContext} shortcut="@">
498
+ {props.contextLabel}
499
+ </MenuV2.Item>
500
+ <MenuV2.Item onSelect={props.onShell} shortcut="!">
501
+ {props.shellLabel}
502
+ </MenuV2.Item>
503
+ </MenuV2.Content>
504
+ </MenuV2.Portal>
505
+ </MenuV2>
506
+ </TooltipV2>
507
+ )
508
+ }
509
+
510
+ function PromptInputV2ConfiguredSelect(props: {
511
+ title: string
512
+ keybind?: string[]
513
+ control: PromptInputV2SelectControl
514
+ model?: boolean
515
+ }) {
516
+ const current = () => props.control.current()
517
+ const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
518
+ return (
519
+ <PromptInputV2Select
520
+ title={props.title}
521
+ keybind={props.control.keybind?.() ?? props.keybind}
522
+ options={props.control.options()}
523
+ current={current()}
524
+ currentIcon={
525
+ <Show when={props.model && providerID()}>
526
+ <ProviderIcon id={providerID()!} class="size-4 shrink-0 opacity-60" />
527
+ </Show>
528
+ }
529
+ onSelect={props.control.onSelect}
530
+ />
531
+ )
532
+ }
533
+
534
+ export function PromptInputV2Select(props: {
535
+ title: string
536
+ keybind?: string[]
537
+ options: PromptInputV2Option[]
538
+ current: string
539
+ currentIcon?: JSX.Element
540
+ class?: string
541
+ onOpenChange?: (open: boolean) => void
542
+ onSelect: (id: string) => void
543
+ }) {
544
+ return (
545
+ <TooltipV2
546
+ placement="top"
547
+ value={
548
+ <>
549
+ {props.title}
550
+ <KeybindV2 keys={props.keybind ?? []} variant="neutral" />
551
+ </>
552
+ }
553
+ >
554
+ <MenuV2 gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
555
+ <MenuV2.Trigger
556
+ as={ButtonV2}
557
+ variant="ghost-muted"
558
+ size="normal"
559
+ class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
560
+ aria-label={props.title}
561
+ >
562
+ {props.currentIcon}
563
+ <span class="truncate capitalize leading-5">
564
+ {props.options.find((option) => option.id === props.current)?.label ?? props.current}
565
+ </span>
566
+ <span class="-ml-0.5 -mr-1 flex shrink-0">
567
+ <IconV2 name="chevron-down" />
568
+ </span>
569
+ </MenuV2.Trigger>
570
+ <MenuV2.Portal>
571
+ <MenuV2.Content>
572
+ <MenuV2.RadioGroup value={props.current} onChange={props.onSelect}>
573
+ <For each={props.options}>
574
+ {(option) => (
575
+ <MenuV2.RadioItem value={option.id} class="capitalize" closeOnSelect>
576
+ {option.label}
577
+ </MenuV2.RadioItem>
578
+ )}
579
+ </For>
580
+ </MenuV2.RadioGroup>
581
+ </MenuV2.Content>
582
+ </MenuV2.Portal>
583
+ </MenuV2>
584
+ </TooltipV2>
585
+ )
586
+ }
587
+
588
+ export function PromptInputV2Popover(props: {
589
+ emptyLabel: string
590
+ items: PromptInputV2Suggestion[]
591
+ activeID?: string
592
+ search?: {
593
+ value: string
594
+ label: string
595
+ placeholder: string
596
+ onValueChange: (value: string) => void
597
+ onKeyDown: (event: KeyboardEvent) => void
598
+ }
599
+ onActiveChange: (item: PromptInputV2Suggestion) => void
600
+ onSelect: (item: PromptInputV2Suggestion) => void
601
+ }) {
602
+ return (
603
+ <div
604
+ class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
605
+ onMouseDown={(event) => event.preventDefault()}
606
+ >
607
+ <Show when={props.search}>
608
+ {(search) => (
609
+ <div class="px-2 py-1">
610
+ <input
611
+ ref={(element) => requestAnimationFrame(() => element.focus())}
612
+ value={search().value}
613
+ aria-label={search().label}
614
+ placeholder={search().placeholder}
615
+ class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
616
+ onInput={(event) => search().onValueChange(event.currentTarget.value)}
617
+ onKeyDown={(event) => search().onKeyDown(event)}
618
+ onMouseDown={(event) => event.stopPropagation()}
619
+ />
620
+ </div>
621
+ )}
622
+ </Show>
623
+ <Show
624
+ when={props.items.length > 0}
625
+ fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
626
+ >
627
+ <For each={props.items}>
628
+ {(item) => (
629
+ <button
630
+ type="button"
631
+ data-suggestion-id={item.id}
632
+ class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-v2-overlay-simple-overlay-hover"
633
+ classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
634
+ onPointerMove={() => props.onActiveChange(item)}
635
+ onClick={() => props.onSelect(item)}
636
+ >
637
+ <div class="flex min-w-0 flex-1 items-center gap-2">
638
+ <PromptInputV2SuggestionIcon item={item} />
639
+ <span class="shrink-0 text-v2-text-text-base">{item.label}</span>
640
+ <Show when={item.description}>
641
+ <span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
642
+ </Show>
643
+ </div>
644
+ <Show when={item.keybind?.length}>
645
+ <span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
646
+ </Show>
647
+ </button>
648
+ )}
649
+ </For>
650
+ </Show>
651
+ </div>
652
+ )
653
+ }
654
+
655
+ export function PromptInputV2SubmitButton(props: {
656
+ mode: PromptInputV2Mode
657
+ stopping: boolean
658
+ disabled: boolean
659
+ sendLabel: string
660
+ stopLabel: string
661
+ onSubmit: () => void
662
+ onStop: () => void
663
+ }) {
664
+ return (
665
+ <TooltipV2
666
+ placement="top"
667
+ inactive={!props.stopping && props.disabled}
668
+ value={props.stopping ? props.stopLabel : props.sendLabel}
669
+ >
670
+ <IconButton
671
+ data-action="prompt-submit"
672
+ type="button"
673
+ disabled={!props.stopping && props.disabled}
674
+ tabIndex={props.mode === "normal" ? undefined : -1}
675
+ icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
676
+ variant="primary"
677
+ class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
678
+ style={{
679
+ "background-image":
680
+ "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
681
+ }}
682
+ aria-label={props.stopping ? props.stopLabel : props.sendLabel}
683
+ onClick={(event) => {
684
+ event.preventDefault()
685
+ event.stopPropagation()
686
+ if (props.stopping) {
687
+ props.onStop()
688
+ return
689
+ }
690
+ props.onSubmit()
691
+ }}
692
+ />
693
+ </TooltipV2>
694
+ )
695
+ }
696
+
697
+ function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) {
698
+ if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
699
+ if (props.item.kind === "command") return null
700
+ return (
701
+ <FileIcon
702
+ node={{ path: props.item.path ?? props.item.label, type: props.item.kind === "reference" ? "directory" : "file" }}
703
+ class="size-4 shrink-0"
704
+ />
705
+ )
706
+ }