diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index d64a8cddf64..4eefc949783 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 -const historyPageSize = 200 +const historyPageSize = 50 const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => { const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` return [ diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8a77848fa94..b25fcef7f8f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -52,10 +52,9 @@ import { DirectoryDataProvider } from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" +import { legacySessionServer, sessionHref } from "./utils/session-route" import { decode64 } from "@/utils/base64" - -import { TargetSessionRouteContent } from "@/pages/session" +import { TargetSessionRoute } from "@/pages/session-lazy" import { Home } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -76,30 +75,6 @@ const DirectoryDraftRedirect = () => { return null } -function TargetServerRoute(props: ParentProps) { - const params = useParams<{ serverKey: string; id: string }>() - const global = useGlobal() - const conn = createMemo(() => { - const key = requireServerKey(params.serverKey) - return global.servers.list().find((item) => ServerConnection.key(item) === key) - }) - - return ( - // Owns the server-identity remount. Session changes must not remount this subtree. - - - {props.children} - - - ) -} - -const TargetSessionRoute = () => ( - - - -) - // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // server via ServerKey, then provide the server-scoped shell for that server. function SelectedServerProviders(props: ParentProps) { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 40bdddee5b3..ea49e9cd378 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -16,7 +16,7 @@ type MessageApi = ServerApi["message"] const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const initialMessagePageSize = 20 -const historyMessagePageSize = 200 +const historyMessagePageSize = 50 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() @@ -45,6 +45,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] { ] } +function yieldToMain() { + const scheduler = (globalThis as { scheduler?: { yield: () => Promise } }).scheduler + if (scheduler) return scheduler.yield() + return new Promise((resolve) => setTimeout(resolve, 0)) +} + function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { const boundary = source.find( (message) => @@ -538,6 +544,7 @@ export function createServerSession( if (!response.data.length) break } const response = pages.at(-1)! + await yieldToMain() const source = pages.flatMap((page) => page.data).toReversed() const normalized = normalizeSessionMessages(sessionID, source) return { diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 0e0ed0286f8..9237e28c19c 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -28,3 +28,4 @@ export { } from "./wsl/types" export { ServerConnection } from "./context/server" export { createDraftStore, type DraftStore } from "./utils/draft-store" +export { preloadSessionRoute } from "./pages/session-lazy" diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 443169aa0e7..9f3d43d7709 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,4 +1,5 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { onCleanup, onMount } from "solid-js" import { createHomeController } from "./home/home-controller" import { createHomeProjectsController } from "./home/home-projects-controller" import { HomeUtilityNav } from "./home/home-projects-view" @@ -7,8 +8,23 @@ import { createHomeScrollController } from "./home/home-scroll-controller" import { createHomeSessionSearchController } from "./home/home-session-search-controller" import { createHomeSessionsController } from "./home/home-sessions-controller" import { HomeSessions } from "./home/home-sessions" +import { preloadSessionRoute } from "./session-lazy" export function Home() { + onMount(() => { + let idle: number | undefined + const timer = setTimeout(() => { + if ("requestIdleCallback" in window) { + idle = requestIdleCallback(() => void preloadSessionRoute(), { timeout: 3_000 }) + return + } + void preloadSessionRoute() + }, 1_500) + onCleanup(() => { + clearTimeout(timer) + if (idle !== undefined) cancelIdleCallback(idle) + }) + }) const home = createHomeController() const projects = createHomeProjectsController(home) const sessions = createHomeSessionsController(home) diff --git a/packages/app/src/pages/session-lazy.ts b/packages/app/src/pages/session-lazy.ts new file mode 100644 index 00000000000..553d3d71e95 --- /dev/null +++ b/packages/app/src/pages/session-lazy.ts @@ -0,0 +1,4 @@ +import { lazy } from "solid-js" + +export const TargetSessionRoute = lazy(() => import("./target-session-route")) +export const preloadSessionRoute = TargetSessionRoute.preload diff --git a/packages/app/src/pages/target-session-route.tsx b/packages/app/src/pages/target-session-route.tsx new file mode 100644 index 00000000000..e8fa16a0c0d --- /dev/null +++ b/packages/app/src/pages/target-session-route.tsx @@ -0,0 +1,27 @@ +import { createMemo, Show } from "solid-js" +import { useParams } from "@solidjs/router" +import { useGlobal } from "@/context/global" +import { ServerConnection } from "@/context/server" +import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSyncProvider } from "@/context/server-sync" +import { requireServerKey } from "@/utils/session-route" +import { TargetSessionRouteContent } from "./session" + +export default function TargetSessionRoute() { + const params = useParams<{ serverKey: string }>() + const global = useGlobal() + const connection = createMemo(() => { + const key = requireServerKey(params.serverKey) + return global.servers.list().find((item) => ServerConnection.key(item) === key) + }) + + return ( + + + + + + + + ) +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index c4abbd9a553..6221994d67d 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -9,6 +9,7 @@ import { type Locale, type Platform, PlatformProvider, + preloadSessionRoute, createDraftStore, ServerConnection, useCommand, @@ -442,7 +443,9 @@ render(() => { const api = window.api as typeof window.api & { getWindowID?: () => Promise } - return { id: await api.getWindowID?.() } + const id = await api.getWindowID?.() + if (/^\/server\/[^/]+\/session\/[^/]+/.test(getLastActiveUrl(id ?? "browser"))) await preloadSessionRoute() + return { id } }) return ( diff --git a/packages/session-ui/src/components/markdown-stream.test.ts b/packages/session-ui/src/components/markdown-stream.test.ts index baa0803f44b..ce18bc628f1 100644 --- a/packages/session-ui/src/components/markdown-stream.test.ts +++ b/packages/session-ui/src/components/markdown-stream.test.ts @@ -158,6 +158,12 @@ describe("markdown stream", () => { expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" }) }) + test("splits completed markdown into bounded top-level blocks", () => { + const result = project(undefined, "# Plan\n\nFirst paragraph.\n\nSecond paragraph.", false) + + expect(result.blocks.map((block) => block.raw)).toEqual(["# Plan", "First paragraph.", "Second paragraph."]) + }) + 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) diff --git a/packages/session-ui/src/components/markdown-stream.ts b/packages/session-ui/src/components/markdown-stream.ts index 18cf4157beb..efebf4ba710 100644 --- a/packages/session-ui/src/components/markdown-stream.ts +++ b/packages/session-ui/src/components/markdown-stream.ts @@ -51,7 +51,7 @@ function heal(text: string) { } export function stream(text: string, live: boolean): Block[] { - if (!live) return completedProjection(text).blocks + if (!live) return completedBlocks(text) 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") @@ -85,6 +85,17 @@ export function stream(text: string, live: boolean): Block[] { return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }] } +function completedBlocks(text: string) { + if (refs(text)) return completedProjection(text).blocks + const tokens = marked.lexer(text) + return tokens.flatMap((token): Block[] => { + if (token.type === "space") return [] + if (token.type !== "code") return [{ raw: token.raw, src: token.raw, mode: "full" }] + const code = token as Tokens.Code + return [{ raw: code.raw, src: code.text, mode: "code", language: language(code.lang), complete: true }] + }) +} + export function project(previous: Projection | undefined, text: string, live: boolean): Projection { if (!live) { const current = @@ -93,7 +104,7 @@ export function project(previous: Projection | undefined, text: string, live: bo : previous && text.startsWith(previous.text) ? project(previous, text, true) : undefined - if (!current) return completedProjection(text) + if (!current) return { text, blocks: completedBlocks(text) } return { text, blocks: current.blocks.map((block) => { diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index d5157b0a350..0a5c48383be 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -491,6 +491,8 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined + let renderFrame: number | undefined + let renderGeneration = 0 createEffect(() => { const container = root() @@ -499,6 +501,9 @@ export function Markdown( const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : [] if (!container) return if (isServer) return + const generation = ++renderGeneration + if (renderFrame !== undefined) cancelAnimationFrame(renderFrame) + renderFrame = undefined if (content.length === 0) { disposeCopyButtons(container) container.innerHTML = "" @@ -515,24 +520,40 @@ export function Markdown( }) activeCodeKeys.clear() nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) - content.forEach((block, index) => updateBlock(container, index, block, labels)) - while (container.children.length > content.length) { - const child = container.lastElementChild - if (!child) break - disposeCopyButtons(child) - child.remove() + let index = 0 + const update = () => { + renderFrame = undefined + if (generation !== renderGeneration) return + const deadline = performance.now() + 8 + while (index < content.length && performance.now() < deadline) { + updateBlock(container, index, content[index]!, labels) + index += 1 + } + if (index < content.length) { + renderFrame = requestAnimationFrame(update) + return + } + while (container.children.length > content.length) { + const child = container.lastElementChild + if (!child) break + disposeCopyButtons(child) + child.remove() + } + container + .querySelectorAll('[data-slot="markdown-copy-button"]') + .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) + if (!copyCleanup) + copyCleanup = setupCodeCopy(container, () => ({ + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + })) } - container - .querySelectorAll('[data-slot="markdown-copy-button"]') - .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) - if (!copyCleanup) - copyCleanup = setupCodeCopy(container, () => ({ - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - })) + update() }) onCleanup(() => { + renderGeneration += 1 + if (renderFrame !== undefined) cancelAnimationFrame(renderFrame) if (copyCleanup) copyCleanup() disposeMarkdownProjection(owner) activeCodeKeys.forEach(disposeCode) diff --git a/packages/session-ui/src/pierre/worker.ts b/packages/session-ui/src/pierre/worker.ts index 1c1bebb40b5..9456bddc70a 100644 --- a/packages/session-ui/src/pierre/worker.ts +++ b/packages/session-ui/src/pierre/worker.ts @@ -24,7 +24,7 @@ function createPool(lineDiffType: "none" | "word-alt") { { theme: "OpenCode", lineDiffType, - preferredHighlighter: "shiki-wasm", + preferredHighlighter: "shiki-js", }, ) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 66cae4f5f17..69c2d55bac4 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -53,9 +53,11 @@ export function PromptInputV2(props: PromptInputV2Props) { const view = props.controller.view let editor: HTMLDivElement | undefined let localInput = false - const updateCursor = () => { + const updateCursor = (event: KeyboardEvent | PointerEvent) => { if (!editor || !window.getSelection()?.isCollapsed) return - props.controller.onCursor(promptInputV2Cursor(editor)) + if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) + return + props.controller.onCursor(parsePromptInputV2Editor(editor).cursor) } const mode = createMemo(() => state.mode) const buttons = createMemo(() => ({ @@ -164,8 +166,7 @@ export function PromptInputV2(props: PromptInputV2Props) { 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" classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} onInput={(event) => { - const cursor = promptInputV2Cursor(event.currentTarget) - const prompt = parsePromptInputV2Editor(event.currentTarget) + const { prompt, cursor } = parsePromptInputV2Editor(event.currentTarget) const images = props.controller.parts().filter((part) => part.type === "image") localInput = true props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) @@ -302,8 +303,13 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2 function parsePromptInputV2Editor(editor: HTMLDivElement) { const parts: Exclude[] = [] + const selection = window.getSelection() + const anchorNode = selection && editor.contains(selection.anchorNode) ? selection.anchorNode : undefined + const anchorOffset = anchorNode ? selection!.anchorOffset : 0 let buffer = "" let position = 0 + let cursor: number | undefined + const offset = () => position + buffer.length const flush = () => { if (!buffer) return @@ -338,43 +344,42 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) { } const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { + if (node === anchorNode) cursor = offset() + Math.min(anchorOffset, node.textContent?.length ?? 0) buffer += node.textContent ?? "" return } if (!(node instanceof HTMLElement)) return if (node.dataset.mention) { + if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? (node.textContent?.length ?? 0) : 0) mention(node) return } if (node.tagName === "BR") { + if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? 1 : 0) buffer += "\n" return } - Array.from(node.childNodes).forEach(visit) + Array.from(node.childNodes).forEach((child, index) => { + if (node === anchorNode && anchorOffset === index) cursor = offset() + visit(child) + }) + if (node === anchorNode && anchorOffset >= node.childNodes.length) cursor = offset() } Array.from(editor.childNodes).forEach((node, index, nodes) => { + if (editor === anchorNode && anchorOffset === index) cursor = offset() visit(node) if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" }) + if (editor === anchorNode && anchorOffset >= editor.childNodes.length) cursor = offset() flush() - if ( - parts.every((part) => part.type === "text") && - parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") - ) { - return [{ type: "text" as const, content: "", start: 0, end: 0 }] - } - if (parts.length > 0) return parts - return [{ type: "text" as const, content: "", start: 0, end: 0 }] -} - -function promptInputV2Cursor(editor: HTMLDivElement) { - const selection = window.getSelection() - if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(editor) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length + const result = + parts.length === 0 || + (parts.every((part) => part.type === "text") && + parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")) + ? [{ type: "text" as const, content: "", start: 0, end: 0 }] + : parts + return { prompt: result, cursor: cursor ?? offset() } } export function PromptInputV2Attachments(props: {