Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
Expand Down
5 changes: 1 addition & 4 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
Expand Down Expand Up @@ -405,9 +404,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/pages/home/home-sessions-controller.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
Expand Down Expand Up @@ -44,7 +43,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const marked = useMarked()
const projectDirectories = createMemo(() => {
const project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
Expand Down Expand Up @@ -119,7 +117,7 @@ export function createHomeSessionsController(home: HomeController) {
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
return preloadMarkdown(part.text, part.id)
}),
),
),
Expand Down
4 changes: 0 additions & 4 deletions packages/session-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
Expand All @@ -54,11 +53,8 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
Expand Down
39 changes: 15 additions & 24 deletions packages/session-ui/src/components/markdown-cache.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { checksum } from "@opencode-ai/core/util/encode"
import DOMPurify from "dompurify"
import { project } from "./markdown-stream"
import { parseMarkdown } from "./markdown-worker"

export type MarkdownCacheEntry = {
raw: string
Expand Down Expand Up @@ -52,27 +52,18 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
cache.delete(first)
}

export async function preloadMarkdown(
text: string,
cacheKey: string,
parser: { parse(text: string): string | Promise<string> },
) {
await Promise.all(
project(undefined, text, false).blocks.map(async (block, index) => {
if (block.mode === "code") return
const key = `${cacheKey}:${index}:${block.mode}`
const cached = getCachedMarkdown(key)
if (cached?.raw === block.raw) {
touchCachedMarkdown(key, cached)
return
}
const hash = checksum(block.raw)
if (!hash) return
touchCachedMarkdown(key, {
raw: block.raw,
hash,
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
})
}),
)
export async function preloadMarkdown(text: string, cacheKey: string) {
const key = `${cacheKey}:0:full`
const cached = getCachedMarkdown(key)
if (cached?.raw === text) {
touchCachedMarkdown(key, cached)
return
}
const hash = checksum(text)
if (!hash) return
touchCachedMarkdown(key, {
raw: text,
hash,
html: sanitizeMarkdown(await parseMarkdown(text)),
})
Comment on lines +55 to +68
}
18 changes: 0 additions & 18 deletions packages/session-ui/src/components/markdown-preload.test.ts

This file was deleted.

11 changes: 11 additions & 0 deletions packages/session-ui/src/components/markdown-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Block, Projection } from "./markdown-stream"

export function completedProjection(text: string): Projection {
return { text, blocks: [{ raw: text, src: text, mode: "full" }] }
}

export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
if (!current || current.mode !== next.mode) return false
if (next.mode === "code" || next.mode === "live") return next.raw.startsWith(current.raw)
return current.raw === next.raw
}
39 changes: 38 additions & 1 deletion packages/session-ui/src/components/markdown-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { canReusePendingBlock, project, stream } from "./markdown-stream"
import { canReusePendingBlock } from "./markdown-projection"
import { project, stream } from "./markdown-stream"

describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => {
Expand Down Expand Up @@ -129,6 +130,9 @@ describe("markdown stream", () => {
expect(
canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
).toBe(true)
expect(canReusePendingBlock({ mode: "live", raw: "partial" }, { mode: "live", raw: "partial text", src: "" })).toBe(
true,
)
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
})

Expand All @@ -145,6 +149,39 @@ describe("markdown stream", () => {
})
})

test("finalizes only the live tail when streaming stops", () => {
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final item", true)
const final = project(live, live.text, false)

expect(final.blocks[0]).toBe(live.blocks[0])
expect(final.blocks[1]).toBe(live.blocks[1])
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
})

test("catches up paced text before finalizing", () => {
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
const final = project(live, `${live.text} item`, false)

expect(canReusePendingBlock(live.blocks[0], final.blocks[0]!)).toBe(true)
expect(canReusePendingBlock(live.blocks[1], final.blocks[1]!)).toBe(true)
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
})

test("completes an open code block when streaming stops", () => {
const live = project(undefined, "```ts\nconst value = 1", true)
const final = project(live, live.text, false)

expect(final.blocks).toEqual([
{
raw: "```ts\nconst value = 1",
src: "const value = 1",
mode: "code",
language: "ts",
complete: true,
},
])
})

test("does not add a blank line before the first streamed code", () => {
const previous = project(undefined, "```ts\n", true)
const next = project(previous, `${previous.text}const x = 1`, true)
Expand Down
28 changes: 20 additions & 8 deletions packages/session-ui/src/components/markdown-stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { marked, type Tokens } from "marked"
import remend from "remend"
import { completedProjection } from "./markdown-projection"

export type Block = {
raw: string
Expand Down Expand Up @@ -50,7 +51,7 @@ function heal(text: string) {
}

export function stream(text: string, live: boolean): Block[] {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
if (!live) return completedProjection(text).blocks
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space")
Expand Down Expand Up @@ -84,14 +85,25 @@ export function stream(text: string, live: boolean): Block[] {
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
}

export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
if (!current || current.mode !== next.mode) return false
if (next.mode === "code") return next.raw.startsWith(current.raw)
return current.raw === next.raw
}

export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
if (!live) {
const current =
previous?.text === text
? previous
: previous && text.startsWith(previous.text)
? project(previous, text, true)
: undefined
if (!current) return completedProjection(text)
return {
text,
blocks: current.blocks.map((block) => {
if (block.mode === "live") return { raw: block.raw, src: block.raw, mode: "full" }
if (block.mode === "code" && !block.complete) return { ...block, complete: true }
return block
}),
}
}
if (!previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
const tail = previous.blocks.at(-1)
const suffix = text.slice(previous.text.length)
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const response = (id: number, reset: boolean, stable: [string, string][], unstab
type: "highlight" as const,
id,
key: "code",
language: "typescript",
reset,
stable,
unstable,
Expand All @@ -20,6 +21,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
type: "highlight",
id: 1,
key: "code",
language: "typescript",
reset: true,
stable: [token("one\n")],
unstable: [token("tw")],
Expand All @@ -28,13 +30,15 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
type: "highlight",
id: 2,
key: "code",
language: "typescript",
reset: false,
stable: [token("two\n")],
unstable: [token("three")],
})

expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
expect(second.language).toBe("typescript")
})

test("increments generation only when the worker resets token identity", () => {
Expand All @@ -45,12 +49,13 @@ test("increments generation only when the worker resets token identity", () => {
})

test("ignores stale worker responses and resets replacement streams", () => {
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
const current = { id: 2, generation: 1, language: "typescript", stable: [token("current")], unstable: [] }
expect(
applyMarkdownWorkerResponse(current, {
type: "highlight",
id: 1,
key: "code",
language: "typescript",
reset: false,
stable: [token("stale")],
unstable: [],
Expand All @@ -62,6 +67,7 @@ test("ignores stale worker responses and resets replacement streams", () => {
type: "highlight",
id: 3,
key: "code",
language: "typescript",
reset: true,
stable: [token("replacement")],
unstable: [],
Expand Down
Loading
Loading