@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,157 @@
1
+ import { type ComponentProps, createMemo, Show, splitProps } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { Card, CardDescription } from "@neurocode-ai/ui/card"
4
+ import { Collapsible } from "@neurocode-ai/ui/collapsible"
5
+ import { Icon } from "@neurocode-ai/ui/icon"
6
+ import { IconButton } from "@neurocode-ai/ui/icon-button"
7
+ import { Tooltip } from "@neurocode-ai/ui/tooltip"
8
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
9
+
10
+ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
11
+ tool: string
12
+ error: string
13
+ title?: string
14
+ defaultOpen?: boolean
15
+ open?: boolean
16
+ onOpenChange?: (open: boolean) => void
17
+ subtitle?: string
18
+ href?: string
19
+ }
20
+
21
+ export function ToolErrorCard(props: ToolErrorCardProps) {
22
+ const i18n = useI18n()
23
+ const [state, setState] = createStore({
24
+ open: props.defaultOpen ?? false,
25
+ copied: false,
26
+ })
27
+ const open = () => props.open ?? state.open
28
+ const copied = () => state.copied
29
+ const [split, rest] = splitProps(props, [
30
+ "tool",
31
+ "error",
32
+ "title",
33
+ "defaultOpen",
34
+ "open",
35
+ "onOpenChange",
36
+ "subtitle",
37
+ "href",
38
+ ])
39
+ const setOpen = (value: boolean) => {
40
+ if (props.open === undefined) setState("open", value)
41
+ props.onOpenChange?.(value)
42
+ }
43
+ const name = createMemo(() => {
44
+ if (split.title) return split.title
45
+ const map: Record<string, string> = {
46
+ read: "ui.tool.read",
47
+ list: "ui.tool.list",
48
+ glob: "ui.tool.glob",
49
+ grep: "ui.tool.grep",
50
+ task: "ui.tool.task",
51
+ webfetch: "ui.tool.webfetch",
52
+ websearch: "ui.tool.websearch",
53
+ bash: "ui.tool.shell",
54
+ shell: "ui.tool.shell",
55
+ patch: "ui.tool.patch",
56
+ apply_patch: "ui.tool.patch",
57
+ question: "ui.tool.questions",
58
+ }
59
+ const key = map[split.tool]
60
+ if (!key) return split.tool
61
+ if (!key.includes(".")) return key
62
+ return i18n.t(key)
63
+ })
64
+ const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim())
65
+ const tail = createMemo(() => {
66
+ const value = cleaned()
67
+ const prefix = `${split.tool} `
68
+ if (value.startsWith(prefix)) return value.slice(prefix.length)
69
+ return value
70
+ })
71
+
72
+ const subtitle = createMemo(() => {
73
+ if (split.subtitle) return split.subtitle
74
+ const parts = tail().split(": ")
75
+ if (parts.length <= 1) return i18n.t("ui.toolErrorCard.failed")
76
+ const head = (parts[0] ?? "").trim()
77
+ if (!head) return i18n.t("ui.toolErrorCard.failed")
78
+ return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed")
79
+ })
80
+
81
+ const body = createMemo(() => {
82
+ const parts = tail().split(": ")
83
+ if (parts.length <= 1) return cleaned()
84
+ return parts.slice(1).join(": ").trim() || cleaned()
85
+ })
86
+
87
+ const copy = async () => {
88
+ const text = cleaned()
89
+ if (!text) return
90
+ await navigator.clipboard.writeText(text)
91
+ setState("copied", true)
92
+ setTimeout(() => setState("copied", false), 2000)
93
+ }
94
+
95
+ return (
96
+ <Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
97
+ <Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
98
+ <Collapsible.Trigger>
99
+ <div data-component="tool-trigger">
100
+ <div data-slot="basic-tool-tool-trigger-content">
101
+ <span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
102
+ <Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
103
+ </span>
104
+ <div data-slot="basic-tool-tool-info">
105
+ <div data-slot="basic-tool-tool-info-structured">
106
+ <div data-slot="basic-tool-tool-info-main">
107
+ <span data-slot="basic-tool-tool-title">{name()}</span>
108
+ <Show
109
+ when={split.href && split.subtitle}
110
+ fallback={<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
111
+ >
112
+ <a
113
+ data-slot="basic-tool-tool-subtitle"
114
+ class="clickable subagent-link"
115
+ href={split.href!}
116
+ onClick={(e) => e.stopPropagation()}
117
+ >
118
+ {subtitle()}
119
+ </a>
120
+ </Show>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ <Collapsible.Arrow />
126
+ </div>
127
+ </Collapsible.Trigger>
128
+ <Collapsible.Content>
129
+ <div data-slot="tool-error-card-content">
130
+ <Show when={open()}>
131
+ <div data-slot="tool-error-card-copy">
132
+ <Tooltip
133
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
134
+ placement="top"
135
+ gutter={4}
136
+ >
137
+ <IconButton
138
+ icon={copied() ? "check" : "copy"}
139
+ size="normal"
140
+ variant="ghost"
141
+ onMouseDown={(e) => e.preventDefault()}
142
+ onClick={(e) => {
143
+ e.stopPropagation()
144
+ void copy()
145
+ }}
146
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
147
+ />
148
+ </Tooltip>
149
+ </div>
150
+ </Show>
151
+ <Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
152
+ </div>
153
+ </Collapsible.Content>
154
+ </Collapsible>
155
+ </Card>
156
+ )
157
+ }
@@ -0,0 +1,89 @@
1
+ [data-component="tool-status-title"] {
2
+ display: inline-flex;
3
+ align-items: baseline;
4
+ white-space: nowrap;
5
+ text-align: start;
6
+
7
+ [data-slot="tool-status-suffix"] {
8
+ display: inline-flex;
9
+ align-items: baseline;
10
+ white-space: nowrap;
11
+ }
12
+
13
+ [data-slot="tool-status-prefix"] {
14
+ white-space: nowrap;
15
+ flex-shrink: 0;
16
+ }
17
+
18
+ [data-slot="tool-status-swap"],
19
+ [data-slot="tool-status-tail"] {
20
+ display: inline-grid;
21
+ overflow: hidden;
22
+ justify-items: start;
23
+ transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
24
+ }
25
+
26
+ [data-slot="tool-status-active"],
27
+ [data-slot="tool-status-done"] {
28
+ grid-area: 1 / 1;
29
+ white-space: nowrap;
30
+ justify-self: start;
31
+ text-align: start;
32
+ transition-property: opacity, filter, transform;
33
+ transition-duration:
34
+ var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
35
+ calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
36
+ transition-timing-function: ease-out, ease-out, ease-out;
37
+ }
38
+
39
+ &[data-ready="false"] {
40
+ [data-slot="tool-status-swap"],
41
+ [data-slot="tool-status-tail"] {
42
+ transition-duration: 0ms;
43
+ }
44
+
45
+ [data-slot="tool-status-active"],
46
+ [data-slot="tool-status-done"] {
47
+ transition-duration: 0ms;
48
+ }
49
+ }
50
+
51
+ [data-slot="tool-status-active"] {
52
+ opacity: 0;
53
+ filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
54
+ transform: translateY(0.03em);
55
+ }
56
+
57
+ [data-slot="tool-status-done"] {
58
+ color: var(--text-strong);
59
+ opacity: 1;
60
+ filter: blur(0);
61
+ transform: translateY(0);
62
+ }
63
+
64
+ &[data-active="true"] {
65
+ [data-slot="tool-status-active"] {
66
+ opacity: 1;
67
+ filter: blur(0);
68
+ transform: translateY(0);
69
+ }
70
+
71
+ [data-slot="tool-status-done"] {
72
+ opacity: 0;
73
+ filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
74
+ transform: translateY(0.03em);
75
+ }
76
+ }
77
+ }
78
+
79
+ @media (prefers-reduced-motion: reduce) {
80
+ [data-component="tool-status-title"] [data-slot="tool-status-swap"],
81
+ [data-component="tool-status-title"] [data-slot="tool-status-tail"] {
82
+ transition-duration: 0ms;
83
+ }
84
+
85
+ [data-component="tool-status-title"] [data-slot="tool-status-active"],
86
+ [data-component="tool-status-title"] [data-slot="tool-status-done"] {
87
+ transition-duration: 0ms;
88
+ }
89
+ }
@@ -0,0 +1,133 @@
1
+ import { Show, createEffect, createMemo, on, onCleanup } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { TextShimmer } from "@neurocode-ai/ui/text-shimmer"
4
+
5
+ function common(active: string, done: string) {
6
+ const a = Array.from(active)
7
+ const b = Array.from(done)
8
+ let i = 0
9
+ while (i < a.length && i < b.length && a[i] === b[i]) i++
10
+ return {
11
+ prefix: a.slice(0, i).join(""),
12
+ active: a.slice(i).join(""),
13
+ done: b.slice(i).join(""),
14
+ }
15
+ }
16
+
17
+ function contentWidth(el: HTMLSpanElement | undefined) {
18
+ if (!el) return
19
+ return `${Math.ceil(el.getBoundingClientRect().width)}px`
20
+ }
21
+
22
+ export function ToolStatusTitle(props: {
23
+ active: boolean
24
+ activeText: string
25
+ doneText: string
26
+ class?: string
27
+ split?: boolean
28
+ }) {
29
+ const split = createMemo(() => common(props.activeText, props.doneText))
30
+ const suffix = createMemo(
31
+ () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
32
+ )
33
+ const prefixLen = createMemo(() => Array.from(split().prefix).length)
34
+ const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
35
+ const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
36
+
37
+ const [state, setState] = createStore({
38
+ active: props.active,
39
+ animating: false,
40
+ width: undefined as string | undefined,
41
+ })
42
+ const width = () => state.width
43
+ const active = () => state.active
44
+ const animating = () => state.animating
45
+ let activeRef: HTMLSpanElement | undefined
46
+ let doneRef: HTMLSpanElement | undefined
47
+ let widthRef: HTMLSpanElement | undefined
48
+ let frame: number | undefined
49
+ let finishTimer: ReturnType<typeof setTimeout> | undefined
50
+
51
+ const finish = () => {
52
+ if (frame !== undefined) cancelAnimationFrame(frame)
53
+ if (finishTimer !== undefined) clearTimeout(finishTimer)
54
+ frame = undefined
55
+ finishTimer = undefined
56
+ setState("animating", false)
57
+ setState("width", undefined)
58
+ }
59
+
60
+ const animate = () => {
61
+ const first = contentWidth(widthRef)
62
+ const next = props.active
63
+ finish()
64
+ setState("active", next)
65
+ if (!first) return
66
+
67
+ setState("animating", true)
68
+ setState("width", first)
69
+ frame = requestAnimationFrame(() => {
70
+ frame = undefined
71
+ const last = contentWidth(next ? activeRef : doneRef)
72
+ if (!last) {
73
+ finish()
74
+ return
75
+ }
76
+ if (first !== last) setState("width", last)
77
+ finishTimer = setTimeout(finish, 600)
78
+ })
79
+ }
80
+
81
+ createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true }))
82
+
83
+ onCleanup(() => {
84
+ finish()
85
+ })
86
+
87
+ return (
88
+ <span
89
+ data-component="tool-status-title"
90
+ data-active={active() ? "true" : "false"}
91
+ data-ready={animating() ? "true" : "false"}
92
+ data-mode={suffix() ? "suffix" : "swap"}
93
+ class={props.class}
94
+ aria-label={active() ? props.activeText : props.doneText}
95
+ >
96
+ <Show
97
+ when={suffix()}
98
+ fallback={
99
+ <span data-slot="tool-status-swap" ref={widthRef} style={{ width: width() }}>
100
+ <Show when={animating() || active()}>
101
+ <span data-slot="tool-status-active" ref={activeRef}>
102
+ <TextShimmer text={activeTail()} active={active()} offset={0} />
103
+ </span>
104
+ </Show>
105
+ <Show when={animating() || !active()}>
106
+ <span data-slot="tool-status-done" ref={doneRef}>
107
+ <TextShimmer text={doneTail()} active={false} offset={0} />
108
+ </span>
109
+ </Show>
110
+ </span>
111
+ }
112
+ >
113
+ <span data-slot="tool-status-suffix">
114
+ <span data-slot="tool-status-prefix">
115
+ <TextShimmer text={split().prefix} active={active()} offset={0} />
116
+ </span>
117
+ <span data-slot="tool-status-tail" ref={widthRef} style={{ width: width() }}>
118
+ <Show when={animating() || active()}>
119
+ <span data-slot="tool-status-active" ref={activeRef}>
120
+ <TextShimmer text={activeTail()} active={active()} offset={prefixLen()} />
121
+ </span>
122
+ </Show>
123
+ <Show when={animating() || !active()}>
124
+ <span data-slot="tool-status-done" ref={doneRef}>
125
+ <TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
126
+ </span>
127
+ </Show>
128
+ </span>
129
+ </span>
130
+ </Show>
131
+ </span>
132
+ )
133
+ }
@@ -0,0 +1,64 @@
1
+ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@neurocode-ai/sdk/v2"
2
+ import type { FileDiffInfo } from "@neurocode-ai/client/promise"
3
+ import { createSimpleContext } from "@neurocode-ai/ui/context"
4
+ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
5
+
6
+ export type NormalizedProviderListResponse = {
7
+ all: Map<string, Provider>
8
+ default: {
9
+ [key: string]: string
10
+ }
11
+ connected: Array<string>
12
+ }
13
+
14
+ type Data = {
15
+ agent?: {
16
+ name: string
17
+ color?: string
18
+ }[]
19
+ provider?: NormalizedProviderListResponse
20
+ session: Session[]
21
+ session_status: {
22
+ [sessionID: string]: SessionStatus
23
+ }
24
+ session_diff: {
25
+ [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[]
26
+ }
27
+ session_diff_preload?: {
28
+ [sessionID: string]: PreloadMultiFileDiffResult<any>[]
29
+ }
30
+ message: {
31
+ [sessionID: string]: Message[]
32
+ }
33
+ part: {
34
+ [messageID: string]: Part[]
35
+ }
36
+ part_text_accum_delta?: {
37
+ [partID: string]: string
38
+ }
39
+ }
40
+
41
+ export type NavigateToSessionFn = (sessionID: string) => void
42
+
43
+ export type SessionHrefFn = (sessionID: string) => string
44
+
45
+ export const { use: useData, provider: DataProvider } = createSimpleContext({
46
+ name: "Data",
47
+ init: (props: {
48
+ data: Data
49
+ directory: string
50
+ onNavigateToSession?: NavigateToSessionFn
51
+ onSessionHref?: SessionHrefFn
52
+ }) => {
53
+ return {
54
+ get store() {
55
+ return props.data
56
+ },
57
+ get directory() {
58
+ return props.directory
59
+ },
60
+ navigateToSession: props.onNavigateToSession,
61
+ sessionHref: props.onSessionHref,
62
+ }
63
+ },
64
+ })
@@ -0,0 +1 @@
1
+ export * from "./data"
@@ -0,0 +1,83 @@
1
+ export type HoverCommentLine = {
2
+ lineNumber: number
3
+ side?: "additions" | "deletions"
4
+ }
5
+
6
+ export function createHoverCommentUtility(props: {
7
+ label: string
8
+ getHoveredLine: () => HoverCommentLine | undefined
9
+ onSelect: (line: HoverCommentLine) => void
10
+ }) {
11
+ if (typeof document === "undefined") return
12
+
13
+ const button = document.createElement("button")
14
+ button.type = "button"
15
+ button.ariaLabel = props.label
16
+ button.textContent = "+"
17
+ button.style.width = "20px"
18
+ button.style.height = "20px"
19
+ button.style.display = "flex"
20
+ button.style.alignItems = "center"
21
+ button.style.justifyContent = "center"
22
+ button.style.border = "none"
23
+ button.style.borderRadius = "var(--radius-md)"
24
+ button.style.background = "var(--icon-interactive-base)"
25
+ button.style.color = "var(--white)"
26
+ button.style.boxShadow = "var(--shadow-xs)"
27
+ button.style.fontSize = "14px"
28
+ button.style.lineHeight = "1"
29
+ button.style.cursor = "pointer"
30
+ button.style.position = "relative"
31
+ button.style.left = "30px"
32
+ button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
33
+
34
+ let line: HoverCommentLine | undefined
35
+
36
+ const sync = () => {
37
+ const next = props.getHoveredLine()
38
+ if (!next) return
39
+ line = next
40
+ }
41
+
42
+ // The hovered line changes when the pointer moves or when content scrolls under
43
+ // a stationary pointer, so track both with passive listeners instead of polling
44
+ // every animation frame. There is no teardown hook for this utility; like the
45
+ // rAF loop this replaced, the listeners self-detach on the first event after
46
+ // the button leaves the DOM.
47
+ const onHoverInvalidated = () => {
48
+ if (!button.isConnected) {
49
+ document.removeEventListener("pointermove", onHoverInvalidated)
50
+ document.removeEventListener("scroll", onHoverInvalidated, true)
51
+ return
52
+ }
53
+ sync()
54
+ }
55
+
56
+ const open = () => {
57
+ const next = props.getHoveredLine() ?? line
58
+ if (!next) return
59
+ props.onSelect(next)
60
+ }
61
+
62
+ document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
63
+ document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
64
+ button.addEventListener("mouseenter", sync)
65
+ button.addEventListener("mousemove", sync)
66
+ button.addEventListener("pointerdown", (event) => {
67
+ event.preventDefault()
68
+ event.stopPropagation()
69
+ sync()
70
+ })
71
+ button.addEventListener("mousedown", (event) => {
72
+ event.preventDefault()
73
+ event.stopPropagation()
74
+ sync()
75
+ })
76
+ button.addEventListener("click", (event) => {
77
+ event.preventDefault()
78
+ event.stopPropagation()
79
+ open()
80
+ })
81
+
82
+ return button
83
+ }
@@ -0,0 +1,91 @@
1
+ import { type SelectedLineRange } from "@pierre/diffs"
2
+ import { diffLineIndex, diffRowIndex } from "./diff-selection"
3
+
4
+ export type CommentSide = "additions" | "deletions"
5
+
6
+ function annotationIndex(node: HTMLElement) {
7
+ const value = node.dataset.lineAnnotation?.split(",")[1]
8
+ if (!value) return
9
+ const line = parseInt(value, 10)
10
+ if (Number.isNaN(line)) return
11
+ return line
12
+ }
13
+
14
+ function clear(root: ShadowRoot) {
15
+ const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
16
+ for (const node of marked) {
17
+ if (!(node instanceof HTMLElement)) continue
18
+ node.removeAttribute("data-comment-selected")
19
+ }
20
+ }
21
+
22
+ export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
23
+ clear(root)
24
+
25
+ const diffs = root.querySelector("[data-diff]")
26
+ if (!(diffs instanceof HTMLElement)) return
27
+
28
+ const split = diffs.dataset.diffType === "split"
29
+ const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
30
+ (node): node is HTMLElement => node instanceof HTMLElement,
31
+ )
32
+ if (rows.length === 0) return
33
+
34
+ const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
35
+ (node): node is HTMLElement => node instanceof HTMLElement,
36
+ )
37
+
38
+ for (const range of ranges) {
39
+ const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
40
+ if (start === undefined) continue
41
+
42
+ const end = (() => {
43
+ const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
44
+ if (same) return start
45
+ return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
46
+ })()
47
+ if (end === undefined) continue
48
+
49
+ const first = Math.min(start, end)
50
+ const last = Math.max(start, end)
51
+
52
+ for (const row of rows) {
53
+ const idx = diffLineIndex(split, row)
54
+ if (idx === undefined || idx < first || idx > last) continue
55
+ row.setAttribute("data-comment-selected", "")
56
+ }
57
+
58
+ for (const annotation of annotations) {
59
+ const idx = annotationIndex(annotation)
60
+ if (idx === undefined || idx < first || idx > last) continue
61
+ annotation.setAttribute("data-comment-selected", "")
62
+ }
63
+ }
64
+ }
65
+
66
+ export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
67
+ clear(root)
68
+
69
+ const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
70
+ (node): node is HTMLElement => node instanceof HTMLElement,
71
+ )
72
+
73
+ for (const range of ranges) {
74
+ const start = Math.max(1, Math.min(range.start, range.end))
75
+ const end = Math.max(range.start, range.end)
76
+
77
+ for (let line = start; line <= end; line++) {
78
+ const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
79
+ for (const node of nodes) {
80
+ if (!(node instanceof HTMLElement)) continue
81
+ node.setAttribute("data-comment-selected", "")
82
+ }
83
+ }
84
+
85
+ for (const annotation of annotations) {
86
+ const line = annotationIndex(annotation)
87
+ if (line === undefined || line < start || line > end) continue
88
+ annotation.setAttribute("data-comment-selected", "")
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,71 @@
1
+ import { type SelectedLineRange } from "@pierre/diffs"
2
+
3
+ export type DiffSelectionSide = "additions" | "deletions"
4
+
5
+ export function findDiffSide(node: HTMLElement): DiffSelectionSide {
6
+ const line = node.closest("[data-line], [data-alt-line]")
7
+ if (line instanceof HTMLElement) {
8
+ const type = line.dataset.lineType
9
+ if (type === "change-deletion") return "deletions"
10
+ if (type === "change-addition" || type === "change-additions") return "additions"
11
+ }
12
+
13
+ const code = node.closest("[data-code]")
14
+ if (!(code instanceof HTMLElement)) return "additions"
15
+ return code.hasAttribute("data-deletions") ? "deletions" : "additions"
16
+ }
17
+
18
+ export function diffLineIndex(split: boolean, node: HTMLElement) {
19
+ const raw = node.dataset.lineIndex
20
+ if (!raw) return
21
+
22
+ const values = raw
23
+ .split(",")
24
+ .map((x) => parseInt(x, 10))
25
+ .filter((x) => !Number.isNaN(x))
26
+ if (values.length === 0) return
27
+ if (!split) return values[0]
28
+ if (values.length === 2) return values[1]
29
+ return values[0]
30
+ }
31
+
32
+ export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
33
+ const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
34
+ (node): node is HTMLElement => node instanceof HTMLElement,
35
+ )
36
+ if (rows.length === 0) return
37
+
38
+ const target = side ?? "additions"
39
+ for (const row of rows) {
40
+ if (findDiffSide(row) === target) return diffLineIndex(split, row)
41
+ if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
42
+ }
43
+ }
44
+
45
+ export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
46
+ if (!range) return range
47
+ if (!root) return
48
+
49
+ const diffs = root.querySelector("[data-diff]")
50
+ if (!(diffs instanceof HTMLElement)) return
51
+
52
+ const split = diffs.dataset.diffType === "split"
53
+ const start = diffRowIndex(root, split, range.start, range.side)
54
+ const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
55
+
56
+ if (start === undefined || end === undefined) {
57
+ if (root.querySelector("[data-line], [data-alt-line]") == null) return
58
+ return null
59
+ }
60
+ if (start <= end) return range
61
+
62
+ const side = range.endSide ?? range.side
63
+ const swapped: SelectedLineRange = {
64
+ start: range.end,
65
+ end: range.start,
66
+ }
67
+
68
+ if (side) swapped.side = side
69
+ if (range.endSide && range.side) swapped.endSide = range.side
70
+ return swapped
71
+ }