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: {