@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,18 @@
1
+ import { expect, test } from "bun:test"
2
+ import { preloadMarkdown } from "./markdown-cache"
3
+
4
+ test("preloads completed markdown into the render cache", async () => {
5
+ const parsed: string[] = []
6
+ const parser = {
7
+ parse(text: string) {
8
+ parsed.push(text)
9
+ return `<p>${text}</p>`
10
+ },
11
+ }
12
+ const key = `markdown-preload-${crypto.randomUUID()}`
13
+
14
+ await preloadMarkdown("prepared response", key, parser)
15
+ await preloadMarkdown("prepared response", key, parser)
16
+
17
+ expect(parsed).toEqual(["prepared response"])
18
+ })
@@ -0,0 +1,104 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import { ShikiStreamTokenizer } from "@shikijs/stream"
4
+ import {
5
+ bundledLanguages,
6
+ createHighlighter,
7
+ getTokenStyleObject,
8
+ stringifyTokenStyle,
9
+ type BundledLanguage,
10
+ type ThemedToken,
11
+ } from "shiki"
12
+ import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
13
+ import { createLatestWorkerQueue } from "./markdown-worker-queue"
14
+
15
+ type Stream = {
16
+ language: string
17
+ source: string
18
+ tokenizer: ShikiStreamTokenizer
19
+ }
20
+
21
+ const streams = new Map<string, Stream>()
22
+ let highlighter: ReturnType<typeof createHighlighter> | undefined
23
+ const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
24
+ run: highlight,
25
+ supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
26
+ dispose: (key) => void streams.delete(key),
27
+ })
28
+
29
+ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
30
+ if (event.data.type === "init") {
31
+ highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
32
+ return
33
+ }
34
+ if (event.data.type === "dispose") {
35
+ queue.dispose(event.data.key)
36
+ return
37
+ }
38
+
39
+ queue.highlight(event.data)
40
+ }
41
+
42
+ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
43
+ try {
44
+ const instance = await highlighter
45
+ if (!instance) throw new Error("Shiki worker is not initialized")
46
+ const language = request.language in bundledLanguages ? request.language : "text"
47
+ if (!instance.getLoadedLanguages().includes(language))
48
+ await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
49
+
50
+ if (request.complete) {
51
+ const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" })
52
+ streams.delete(request.key)
53
+ post({
54
+ type: "highlight",
55
+ id: request.id,
56
+ key: request.key,
57
+ reset: true,
58
+ stable: result.tokens
59
+ .flatMap((line, index) =>
60
+ index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }],
61
+ )
62
+ .map(token),
63
+ unstable: [],
64
+ })
65
+ return
66
+ }
67
+
68
+ const previous = streams.get(request.key)
69
+ const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source)
70
+ const stream = reset
71
+ ? {
72
+ language,
73
+ source: "",
74
+ tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }),
75
+ }
76
+ : previous
77
+ const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length))
78
+ stream.source = request.text
79
+ streams.set(request.key, stream)
80
+ post({
81
+ type: "highlight",
82
+ id: request.id,
83
+ key: request.key,
84
+ reset,
85
+ stable: result.stable.filter((token) => token.content.length > 0).map(token),
86
+ unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
87
+ })
88
+ } catch (error) {
89
+ post({
90
+ type: "error",
91
+ id: request.id,
92
+ key: request.key,
93
+ message: error instanceof Error ? error.message : String(error),
94
+ })
95
+ }
96
+ }
97
+
98
+ function post(response: MarkdownWorkerResponse) {
99
+ self.postMessage(response)
100
+ }
101
+
102
+ function token(value: ThemedToken): MarkdownToken {
103
+ return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
104
+ }
@@ -0,0 +1,194 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { canReusePendingBlock, project, stream } from "./markdown-stream"
3
+
4
+ describe("markdown stream", () => {
5
+ test("heals incomplete emphasis while streaming", () => {
6
+ expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
7
+ expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
8
+ })
9
+
10
+ test("keeps incomplete links non-clickable until they finish", () => {
11
+ expect(stream("see [docs](https://example.com/gu", true)).toEqual([
12
+ { raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
13
+ ])
14
+ })
15
+
16
+ test("splits an unfinished trailing code fence from stable content", () => {
17
+ expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
18
+ { raw: "before\n\n", src: "before\n\n", mode: "full" },
19
+ { raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" },
20
+ ])
21
+ })
22
+
23
+ test("fully parses a code fence once it closes", () => {
24
+ const text = "before\n\n```ts\nconst x = 1\n```"
25
+ expect(stream(text, true)).toEqual([
26
+ { raw: "before\n\n", src: "before\n\n", mode: "full" },
27
+ { raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true },
28
+ ])
29
+ })
30
+
31
+ test("keeps a completed code fence in worker-rendered code mode when prose follows", () => {
32
+ expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([
33
+ { raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
34
+ { raw: "after", src: "after", mode: "live" },
35
+ ])
36
+ })
37
+
38
+ test("freezes completed top-level blocks and only keeps the tail live", () => {
39
+ expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([
40
+ { raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" },
41
+ { raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" },
42
+ { raw: "- live item", src: "- live item", mode: "live" },
43
+ ])
44
+ })
45
+
46
+ test("keeps a growing table together until a later block freezes it", () => {
47
+ expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([
48
+ { raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" },
49
+ ])
50
+ })
51
+
52
+ test("reprojects non-prefix replacements from current content", () => {
53
+ expect(stream("# Replacement\n\nNew body", true)).toEqual([
54
+ { raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" },
55
+ { raw: "New body", src: "New body", mode: "live" },
56
+ ])
57
+ })
58
+
59
+ test("reprojects truncation without retaining removed blocks", () => {
60
+ expect(stream("Only the restored prefix", true)).toEqual([
61
+ { raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" },
62
+ ])
63
+ })
64
+
65
+ test("shifts later blocks when an earlier block is inserted", () => {
66
+ expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([
67
+ { raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" },
68
+ { raw: "First body\n\n", src: "First body\n\n", mode: "full" },
69
+ { raw: "Second body", src: "Second body", mode: "live" },
70
+ ])
71
+ })
72
+
73
+ test("keeps reference-style markdown as one block", () => {
74
+ expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
75
+ {
76
+ raw: "[docs][1]\n\n[1]: https://example.com",
77
+ src: "[docs][1]\n\n[1]: https://example.com",
78
+ mode: "live",
79
+ },
80
+ ])
81
+ })
82
+
83
+ test("keeps compact and indented reference definitions with their uses", () => {
84
+ expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([
85
+ {
86
+ raw: "[docs]\n\n [docs]:/guide",
87
+ src: "[docs]\n\n [docs]:/guide",
88
+ mode: "live",
89
+ },
90
+ ])
91
+ })
92
+
93
+ test("keeps multiline reference definitions with their uses", () => {
94
+ expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([
95
+ {
96
+ raw: "[docs][id]\n\n[id]:\n /guide",
97
+ src: "[docs][id]\n\n[id]:\n /guide",
98
+ mode: "live",
99
+ },
100
+ ])
101
+ })
102
+
103
+ test("uses only the language portion of fence metadata", () => {
104
+ expect(stream("```ts title=example\nconst x = 1", true)).toEqual([
105
+ {
106
+ raw: "```ts title=example\nconst x = 1",
107
+ src: "const x = 1",
108
+ mode: "code",
109
+ language: "ts",
110
+ },
111
+ ])
112
+ })
113
+
114
+ test("preserves trailing newlines in open code fences", () => {
115
+ expect(stream("```ts\nconst x = 1\n", true)).toEqual([
116
+ {
117
+ raw: "```ts\nconst x = 1\n",
118
+ src: "const x = 1\n",
119
+ mode: "code",
120
+ language: "ts",
121
+ },
122
+ ])
123
+ })
124
+
125
+ test("only reuses pending blocks with compatible identity and content", () => {
126
+ expect(
127
+ canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "" }),
128
+ ).toBe(false)
129
+ expect(
130
+ canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
131
+ ).toBe(true)
132
+ expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
133
+ })
134
+
135
+ test("appends plain code deltas without reprojecting frozen blocks", () => {
136
+ const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true)
137
+ const next = project(previous, `${previous.text}const two = 2\n`, true)
138
+
139
+ expect(next.blocks[0]).toBe(previous.blocks[0])
140
+ expect(next.blocks.at(-1)).toEqual({
141
+ raw: "```ts\nconst one = 1\nconst two = 2\n",
142
+ src: "const one = 1\nconst two = 2\n",
143
+ mode: "code",
144
+ language: "ts",
145
+ })
146
+ })
147
+
148
+ test("does not add a blank line before the first streamed code", () => {
149
+ const previous = project(undefined, "```ts\n", true)
150
+ const next = project(previous, `${previous.text}const x = 1`, true)
151
+
152
+ expect(next.blocks.at(-1)).toEqual({
153
+ raw: "```ts\nconst x = 1",
154
+ src: "const x = 1",
155
+ mode: "code",
156
+ language: "ts",
157
+ })
158
+ })
159
+
160
+ test("closes code fences split across provider deltas", () => {
161
+ const open = project(undefined, "```ts\nconst x = 1\n", true)
162
+ const one = project(open, `${open.text}\``, true)
163
+ const two = project(one, `${one.text}\``, true)
164
+ const closed = project(two, `${two.text}\``, true)
165
+ const prose = project(closed, `${closed.text}\nafter`, true)
166
+
167
+ expect(closed.blocks.at(-1)).toEqual({
168
+ raw: "```ts\nconst x = 1\n```",
169
+ src: "const x = 1",
170
+ mode: "code",
171
+ language: "ts",
172
+ complete: true,
173
+ })
174
+ expect(prose.blocks).toEqual([
175
+ { raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
176
+ { raw: "after", src: "after", mode: "live" },
177
+ ])
178
+ })
179
+
180
+ test("closes tilde fences split across provider deltas", () => {
181
+ const open = project(undefined, "~~~ts\nconst x = 1\n", true)
182
+ const one = project(open, `${open.text}~`, true)
183
+ const two = project(one, `${one.text}~`, true)
184
+ const closed = project(two, `${two.text}~`, true)
185
+
186
+ expect(closed.blocks.at(-1)).toEqual({
187
+ raw: "~~~ts\nconst x = 1\n~~~",
188
+ src: "const x = 1",
189
+ mode: "code",
190
+ language: "ts",
191
+ complete: true,
192
+ })
193
+ })
194
+ })
@@ -0,0 +1,110 @@
1
+ import { marked, type Tokens } from "marked"
2
+ import remend from "remend"
3
+
4
+ export type Block = {
5
+ raw: string
6
+ src: string
7
+ mode: "full" | "live" | "code"
8
+ language?: string
9
+ complete?: boolean
10
+ }
11
+
12
+ export type Projection = {
13
+ text: string
14
+ blocks: Block[]
15
+ }
16
+
17
+ function refs(text: string) {
18
+ if (!text.includes("]:")) return false
19
+ return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
20
+ }
21
+
22
+ function language(value: string | undefined) {
23
+ return value?.trim().split(/\s+/, 1)[0] || undefined
24
+ }
25
+
26
+ function openCode(raw: string) {
27
+ const newline = raw.indexOf("\n")
28
+ return newline < 0 ? "" : raw.slice(newline + 1)
29
+ }
30
+
31
+ function open(raw: string) {
32
+ const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
33
+ if (!match) return false
34
+ const mark = match[1]
35
+ if (!mark) return false
36
+ const char = mark[0]
37
+ const size = mark.length
38
+ const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
39
+ return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
40
+ }
41
+
42
+ function closesFence(raw: string, suffix: string) {
43
+ const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
44
+ if (!mark) return suffix.includes("```") || suffix.includes("~~~")
45
+ return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
46
+ }
47
+
48
+ function heal(text: string) {
49
+ return remend(text, { linkMode: "text-only" })
50
+ }
51
+
52
+ export function stream(text: string, live: boolean): Block[] {
53
+ if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
54
+ if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
55
+ const tokens = marked.lexer(text)
56
+ const tail = tokens.findLastIndex((token) => token.type !== "space")
57
+ if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
58
+ const last = tokens[tail]
59
+ if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
60
+
61
+ const result: Block[] = []
62
+ for (let index = 0; index < tail; index++) {
63
+ const token = tokens[index]
64
+ if (!token || token.type === "space") continue
65
+ let raw = token.raw
66
+ while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
67
+ if (token.type === "code") {
68
+ const code = token as Tokens.Code
69
+ result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
70
+ continue
71
+ }
72
+ result.push({ raw, src: raw, mode: "full" })
73
+ }
74
+
75
+ const raw = tokens
76
+ .slice(tail)
77
+ .map((token) => token.raw)
78
+ .join("")
79
+ if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
80
+
81
+ const code = last as Tokens.Code
82
+ if (!open(code.raw))
83
+ return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
84
+ return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
85
+ }
86
+
87
+ export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
88
+ if (!current || current.mode !== next.mode) return false
89
+ if (next.mode === "code") return next.raw.startsWith(current.raw)
90
+ return current.raw === next.raw
91
+ }
92
+
93
+ export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
94
+ if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
95
+ const tail = previous.blocks.at(-1)
96
+ const suffix = text.slice(previous.text.length)
97
+ if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
98
+ return { text, blocks: stream(text, live) }
99
+ return {
100
+ text,
101
+ blocks: [
102
+ ...previous.blocks.slice(0, -1),
103
+ {
104
+ ...tail,
105
+ raw: tail.raw + suffix,
106
+ src: tail.src + suffix,
107
+ },
108
+ ],
109
+ }
110
+ }
@@ -0,0 +1,81 @@
1
+ import { expect, test } from "bun:test"
2
+ import {
3
+ applyMarkdownWorkerResponse,
4
+ markdownBlockKey,
5
+ shouldReleaseMarkdownWorkerState,
6
+ } from "./markdown-worker-protocol"
7
+
8
+ const token = (content: string): [string, string] => [content, ""]
9
+ const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({
10
+ type: "highlight" as const,
11
+ id,
12
+ key: "code",
13
+ reset,
14
+ stable,
15
+ unstable,
16
+ })
17
+
18
+ test("accumulates stable worker tokens and replaces the unstable tail", () => {
19
+ const first = applyMarkdownWorkerResponse(undefined, {
20
+ type: "highlight",
21
+ id: 1,
22
+ key: "code",
23
+ reset: true,
24
+ stable: [token("one\n")],
25
+ unstable: [token("tw")],
26
+ })
27
+ const second = applyMarkdownWorkerResponse(first, {
28
+ type: "highlight",
29
+ id: 2,
30
+ key: "code",
31
+ reset: false,
32
+ stable: [token("two\n")],
33
+ unstable: [token("three")],
34
+ })
35
+
36
+ expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
37
+ expect(second.unstable.map((item) => item[0])).toEqual(["three"])
38
+ })
39
+
40
+ test("increments generation only when the worker resets token identity", () => {
41
+ const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], []))
42
+ const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], []))
43
+ const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], []))
44
+ expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2])
45
+ })
46
+
47
+ test("ignores stale worker responses and resets replacement streams", () => {
48
+ const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
49
+ expect(
50
+ applyMarkdownWorkerResponse(current, {
51
+ type: "highlight",
52
+ id: 1,
53
+ key: "code",
54
+ reset: false,
55
+ stable: [token("stale")],
56
+ unstable: [],
57
+ }),
58
+ ).toBe(current)
59
+
60
+ expect(
61
+ applyMarkdownWorkerResponse(current, {
62
+ type: "highlight",
63
+ id: 3,
64
+ key: "code",
65
+ reset: true,
66
+ stable: [token("replacement")],
67
+ unstable: [],
68
+ }).stable.map((item) => item[0]),
69
+ ).toEqual(["replacement"])
70
+ })
71
+
72
+ test("releases only the latest completed worker state", () => {
73
+ expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true)
74
+ expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false)
75
+ expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false)
76
+ })
77
+
78
+ test("prefixes pending and dispatched block keys with the component owner", () => {
79
+ expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code")
80
+ expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2")
81
+ })
@@ -0,0 +1,48 @@
1
+ import type { ThemeRegistrationResolved } from "shiki"
2
+
3
+ export type MarkdownToken = [content: string, style: string]
4
+
5
+ export type MarkdownWorkerRequest =
6
+ | { type: "init"; theme: ThemeRegistrationResolved }
7
+ | { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
8
+ | { type: "dispose"; key: string }
9
+
10
+ export type MarkdownWorkerResponse =
11
+ | {
12
+ type: "highlight"
13
+ id: number
14
+ key: string
15
+ reset: boolean
16
+ stable: MarkdownToken[]
17
+ unstable: MarkdownToken[]
18
+ }
19
+ | { type: "error"; id: number; key: string; message: string }
20
+ | { type: "superseded"; id: number; key: string }
21
+
22
+ export type MarkdownWorkerState = {
23
+ id: number
24
+ generation: number
25
+ stable: MarkdownToken[]
26
+ unstable: MarkdownToken[]
27
+ }
28
+
29
+ export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) {
30
+ return complete && latestID === responseID
31
+ }
32
+
33
+ export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) {
34
+ return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}`
35
+ }
36
+
37
+ export function applyMarkdownWorkerResponse(
38
+ state: MarkdownWorkerState | undefined,
39
+ response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
40
+ ) {
41
+ if (state && response.id <= state.id) return state
42
+ return {
43
+ id: response.id,
44
+ generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
45
+ stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
46
+ unstable: response.unstable,
47
+ }
48
+ }
@@ -0,0 +1,49 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createLatestWorkerQueue } from "./markdown-worker-queue"
3
+
4
+ test("keeps only the latest queued request for each key", async () => {
5
+ const processed: number[] = []
6
+ const superseded: number[] = []
7
+ let release = () => {}
8
+ const blocked = new Promise<void>((resolve) => {
9
+ release = resolve
10
+ })
11
+ const queue = createLatestWorkerQueue<{ id: number; key: string }>({
12
+ run: async (request) => {
13
+ processed.push(request.id)
14
+ if (request.id === 1) await blocked
15
+ },
16
+ supersede: (request) => superseded.push(request.id),
17
+ dispose: () => {},
18
+ })
19
+
20
+ queue.highlight({ id: 1, key: "code" })
21
+ await Promise.resolve()
22
+ queue.highlight({ id: 2, key: "code" })
23
+ queue.highlight({ id: 3, key: "code" })
24
+ queue.highlight({ id: 4, key: "code" })
25
+
26
+ expect(queue.pending()).toBe(1)
27
+ expect(superseded).toEqual([2, 3])
28
+ release()
29
+ await queue.idle()
30
+ expect(processed).toEqual([1, 4])
31
+ })
32
+
33
+ test("serializes disposal before a later request for the same key", async () => {
34
+ const events: string[] = []
35
+ const queue = createLatestWorkerQueue<{ id: number; key: string }>({
36
+ run: async (request) => {
37
+ events.push(`highlight:${request.id}`)
38
+ },
39
+ supersede: (request) => events.push(`supersede:${request.id}`),
40
+ dispose: (key) => events.push(`dispose:${key}`),
41
+ })
42
+
43
+ queue.highlight({ id: 1, key: "code" })
44
+ queue.dispose("code")
45
+ queue.highlight({ id: 2, key: "code" })
46
+ await queue.idle()
47
+
48
+ expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"])
49
+ })
@@ -0,0 +1,64 @@
1
+ export function createLatestWorkerQueue<T extends { key: string }>(input: {
2
+ run: (request: T) => Promise<void>
3
+ supersede: (request: T) => void
4
+ dispose: (key: string) => void
5
+ }) {
6
+ type Slot = { type: "highlight"; key: string; request?: T }
7
+ const jobs: Array<Slot | { type: "dispose"; key: string }> = []
8
+ const slots = new Map<string, Slot>()
9
+ let running: Promise<void> | undefined
10
+ let cursor = 0
11
+
12
+ const schedule = () => {
13
+ if (running) return
14
+ running = Promise.resolve()
15
+ .then(async () => {
16
+ while (cursor < jobs.length) {
17
+ const job = jobs[cursor++]!
18
+ if (job.type === "dispose") {
19
+ input.dispose(job.key)
20
+ continue
21
+ }
22
+ if (slots.get(job.key) === job) slots.delete(job.key)
23
+ const request = job.request
24
+ job.request = undefined
25
+ if (request) await input.run(request)
26
+ }
27
+ })
28
+ .finally(() => {
29
+ jobs.splice(0, cursor)
30
+ cursor = 0
31
+ running = undefined
32
+ if (jobs.length > 0) schedule()
33
+ })
34
+ }
35
+
36
+ return {
37
+ highlight(request: T) {
38
+ const slot = slots.get(request.key)
39
+ if (slot) {
40
+ if (slot.request) input.supersede(slot.request)
41
+ slot.request = request
42
+ return
43
+ }
44
+ const next: Slot = { type: "highlight", key: request.key, request }
45
+ slots.set(request.key, next)
46
+ jobs.push(next)
47
+ schedule()
48
+ },
49
+ dispose(key: string) {
50
+ const slot = slots.get(key)
51
+ if (slot?.request) input.supersede(slot.request)
52
+ if (slot) {
53
+ slot.request = undefined
54
+ slots.delete(key)
55
+ }
56
+ jobs.push({ type: "dispose", key })
57
+ schedule()
58
+ },
59
+ pending: () => slots.size,
60
+ async idle() {
61
+ while (running) await running
62
+ },
63
+ }
64
+ }