From 25cb2be61938e1d64377cc0908084356c7210638 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:27:20 -0400 Subject: [PATCH 001/335] fix(mcp): default tool schema properties (#32568) --- packages/opencode/src/session/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index b3bb085a3ce..87582ce0751 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -119,7 +119,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (!execute) continue const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) - const transformed = ProviderTransform.schema(input.model, schema) + const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => run.promise( From 88f5b9a90e0450ebe903c28cd9336e4c2913156d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:43:23 +0200 Subject: [PATCH 002/335] experiment: better web picker using @pierre/tree (#31208) --- bun.lock | 8 + package.json | 1 + packages/app/package.json | 1 + .../components/dialog-select-directory-v2.css | 107 ++++++ .../components/dialog-select-directory-v2.tsx | 340 ++++++++++++++++++ .../components/dialog-select-directory.tsx | 211 +---------- .../app/src/components/dialog-select-file.tsx | 27 +- .../directory-picker-domain.test.ts | 191 ++++++++++ .../src/components/directory-picker-domain.ts | 331 +++++++++++++++++ .../app/src/components/directory-picker.tsx | 24 +- .../app/src/components/pierre-tree.test.ts | 23 ++ packages/app/src/components/prompt-input.tsx | 1 + .../components/prompt-input/attachments.ts | 2 + .../prompt-input/build-request-parts.test.ts | 25 ++ .../prompt-input/build-request-parts.ts | 2 +- packages/app/src/context/platform.tsx | 3 + packages/app/src/context/prompt.tsx | 1 + packages/app/src/i18n/ar.ts | 5 + packages/app/src/i18n/br.ts | 5 + packages/app/src/i18n/bs.ts | 5 + packages/app/src/i18n/da.ts | 5 + packages/app/src/i18n/de.ts | 5 + packages/app/src/i18n/en.ts | 5 + packages/app/src/i18n/es.ts | 5 + packages/app/src/i18n/fr.ts | 5 + packages/app/src/i18n/ja.ts | 5 + packages/app/src/i18n/ko.ts | 5 + packages/app/src/i18n/no.ts | 5 + packages/app/src/i18n/pl.ts | 5 + packages/app/src/i18n/ru.ts | 5 + packages/app/src/i18n/th.ts | 5 + packages/app/src/i18n/tr.ts | 5 + packages/app/src/i18n/uk.ts | 5 + packages/app/src/i18n/zh.ts | 5 + packages/app/src/i18n/zht.ts | 5 + packages/desktop/src/preload/index.ts | 3 +- packages/desktop/src/preload/types.ts | 1 + packages/desktop/src/renderer/index.tsx | 9 +- patches/@pierre%2Ftrees@1.0.0-beta.4.patch | 107 ++++++ 39 files changed, 1297 insertions(+), 211 deletions(-) create mode 100644 packages/app/src/components/dialog-select-directory-v2.css create mode 100644 packages/app/src/components/dialog-select-directory-v2.tsx create mode 100644 packages/app/src/components/directory-picker-domain.test.ts create mode 100644 packages/app/src/components/directory-picker-domain.ts create mode 100644 packages/app/src/components/pierre-tree.test.ts create mode 100644 patches/@pierre%2Ftrees@1.0.0-beta.4.patch diff --git a/bun.lock b/bun.lock index 456c8165e1c..4b927abdb1f 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/trees": "1.0.0-beta.4", "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", @@ -922,6 +923,7 @@ "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -2126,6 +2128,8 @@ "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -4562,6 +4566,10 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="], diff --git a/package.json b/package.json index 2d4f0592df5..08f06188f86 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch" } } diff --git a/packages/app/package.json b/packages/app/package.json index 7a3190ea49b..35054c5023e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -46,6 +46,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/trees": "1.0.0-beta.4", "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", diff --git a/packages/app/src/components/dialog-select-directory-v2.css b/packages/app/src/components/dialog-select-directory-v2.css new file mode 100644 index 00000000000..e1d22f19c68 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.css @@ -0,0 +1,107 @@ +.directory-picker-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + padding: 2px 16px 0; +} + +.directory-picker-v2-path { + position: relative; + z-index: 10; + display: flex; + gap: 8px; +} + +.directory-picker-v2-actions { + display: flex; + flex-shrink: 0; + gap: 2px; +} + +.directory-picker-v2-suggestions { + position: absolute; + z-index: 20; + top: 36px; + right: 0; + left: 0; + display: flex; + flex-direction: column; + padding: 4px; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + box-shadow: var(--v2-elevation-overlay); +} + +.directory-picker-v2-suggestions button { + overflow: hidden; + padding: 6px 8px; + border-radius: 4px; + color: var(--v2-text-text-muted); + font-size: 12px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.directory-picker-v2-suggestions button:hover, +.directory-picker-v2-suggestions button[data-active] { + color: var(--v2-text-text-base); + background: var(--v2-overlay-simple-overlay-hover); +} + +.directory-picker-v2-browser { + position: relative; + z-index: 0; + isolation: isolate; + min-height: 0; + flex: 1; + overflow: auto; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: transparent; +} + +.directory-picker-v2-tree { + display: block; + width: 100%; + height: 100%; + --trees-bg-override: transparent; + --trees-fg-override: var(--v2-text-text-base); + --trees-fg-muted-override: var(--v2-text-text-muted); + --trees-bg-muted-override: transparent; + --trees-selected-bg-override: transparent; + --trees-selected-fg-override: var(--v2-text-text-base); + --trees-selected-focused-border-color-override: transparent; + --trees-focus-ring-color-override: transparent; + --trees-focus-ring-width-override: 0px; + --trees-focus-ring-offset-override: 0px; + --trees-border-color-override: var(--v2-border-border-base); + --trees-font-family-override: var(--font-family-sans); + --trees-font-size-override: 12px; + --trees-item-height: 24px; + --trees-border-radius-override: 4px; +} + +.directory-picker-v2-state { + position: absolute; + z-index: 1; + inset: 0; + display: grid; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 12px; + pointer-events: none; +} + +.directory-picker-v2-selection { + overflow: hidden; + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx new file mode 100644 index 00000000000..940e427ea98 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -0,0 +1,340 @@ +import "@pierre/trees/web-components" +import { FileTree } from "@pierre/trees" +import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { + absoluteTreePath, + activeTreeNavigation, + advanceTreePreload, + nextSuggestionIndex, + nextTreeScrollTop, + pickerFileSearchQuery, + pickerAbsoluteInput, + pickerMode, + preloadTreeDirectories, + cleanPickerInput, + createDirectorySearch, + currentPickerSuggestions, + displayPickerPath, + pickerParent, + pickerRoot, +} from "./directory-picker-domain" +import "./dialog-select-directory-v2.css" + +interface DialogSelectDirectoryV2Props { + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void + server: ServerConnection.Any + mode?: "directory" | "file" + start?: string +} + +export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { + const global = useGlobal() + const { sync, sdk } = global.createServerCtx(props.server) + const dialog = useDialog() + const language = useLanguage() + const policy = pickerMode(props.mode ?? "directory", props.start) + const action = { + file: language.t("dialog.directory.action.selectFile"), + directory: language.t("dialog.directory.action.selectFolder"), + } + const [root, setRoot] = createSignal("") + const [input, setInput] = createSignal("") + const [selected, setSelected] = createSignal("") + const [suggestionsOpen, setSuggestionsOpen] = createSignal(false) + const [activeSuggestion, setActiveSuggestion] = createSignal(-1) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(false) + const [rootValid, setRootValid] = createSignal(false) + const listings = new Map | undefined>>() + const advanced = new Set() + let tree: FileTree | undefined + let container: HTMLDivElement | undefined + let pathArea: HTMLDivElement | undefined + let navigation = 0 + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + () => sdk.client.path.get().then((result) => result.data).catch(() => undefined), + { initialValue: undefined }, + ) + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => props.start || sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) + const [suggestions] = createResource(input, async (value) => { + const typed = cleanPickerInput(value).replace(/\/+$/, "") + const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") + if (!typed || typed === current) return { query: value, items: [] } + const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) + if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.client.find + .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) + .then((result) => result.data ?? []) + .catch(() => []) + const results = [ + ...directories, + ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ] + return { + query: value, + items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8), + } + }) + const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) + + async function load(path: string, generation: number, preload = true) { + const key = path.replace(/\/+$/, "") + setError(false) + const absolute = absoluteTreePath(root(), key) + const request = + listings.get(key) ?? + sdk.client.file + .list({ directory: absolute, path: "" }) + .then((result) => result.data ?? []) + .catch(() => undefined) + listings.set(key, request) + const nodes = await request + if (!activeTreeNavigation(generation, navigation)) return false + if (!nodes) { + listings.delete(key) + if (!key) setError(true) + return false + } + tree?.batch( + policy.entries(key, nodes).map((item) => ({ type: "add", path: item })), + ) + if (preload && advanceTreePreload(advanced, key)) { + void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false))) + } + return true + } + + async function navigate(path: string) { + const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home())) + if (!value) return + const token = ++navigation + setLoading(true) + setRootValid(false) + setSelected("") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + setRoot(value) + setInput(displayPickerPath(value, value, home())) + listings.clear() + advanced.clear() + tree?.resetPaths([]) + const valid = await load("", token) + if (!activeTreeNavigation(token, navigation)) return + setRootValid(valid) + setLoading(false) + } + + function complete() { + const items = currentSuggestions() + const match = items[activeSuggestion()] ?? items[0] + if (!match) return + const value = displayPickerPath(match.absolute, input(), home()) + setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) + if (match.type === "file") { + setSelected( + policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "", + ) + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + } + + function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) { + if (suggestion.type === "directory") { + void navigate(suggestion.absolute) + return + } + setInput(displayPickerPath(suggestion.absolute, input(), home())) + setSelected( + policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "", + ) + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + + function moveSuggestion(delta: -1 | 1) { + setSuggestionsOpen(true) + setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length)) + } + + function activeSuggestionValue() { + const items = currentSuggestions() + return items[activeSuggestion()] ?? items[0] + } + + const keyActions: Partial void>> = { + ArrowDown: () => moveSuggestion(1), + ArrowUp: () => moveSuggestion(-1), + Enter: () => { + const suggestion = activeSuggestionValue() + if (suggestion) chooseSuggestion(suggestion) + if (!suggestion) void navigate(input()) + }, + Tab: complete, + } + + function handleInputKey(event: KeyboardEvent) { + const action = keyActions[event.key] + if (!action) return + if (event.key === "Tab" && event.shiftKey) return + event.preventDefault() + action() + } + + function resolve() { + const path = policy.result(root(), selected(), rootValid()) + if (!path) return + props.onSelect(props.multiple ? [path] : path) + dialog.close() + } + + onMount(() => { + const closeSuggestions = (event: PointerEvent) => { + if (pathArea?.contains(event.target as Node)) return + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + document.addEventListener("pointerdown", closeSuggestions) + onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions)) + tree = new FileTree({ + paths: [], + flattenEmptyDirectories: false, + initialExpansion: "closed", + stickyFolders: true, + unsafeCSS: ` + button[data-type="item"] { + background: transparent !important; + box-shadow: none !important; + } + button[data-type="item"]:hover { + background: var(--v2-overlay-simple-overlay-hover) !important; + } + button[data-type="item"]:focus-visible { + outline: none !important; + box-shadow: none !important; + } + [data-file-tree-virtualized-scroll] { + overscroll-behavior: contain; + scrollbar-width: thin; + } + `, + onExpansionChange(change) { + if (change.expanded) void load(change.path, navigation) + }, + onSelectionChange(paths) { + const path = paths.at(-1) + setSelected(path ? policy.selection(root(), path) ?? "" : "") + }, + }) + if (!container) return + tree.render({ containerWrapper: container }) + tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree") + }) + + createEffect(() => { + const path = start() + if (!path || root()) return + void navigate(path) + }) + + onCleanup(() => tree?.cleanUp()) + + return ( + +
+
+ { + setInput(cleanPickerInput(event.currentTarget.value)) + setSelected("") + setSuggestionsOpen(true) + setActiveSuggestion(-1) + }} + role="combobox" + aria-autocomplete="list" + aria-expanded={suggestionsOpen()} + aria-controls="directory-picker-v2-suggestions" + aria-activedescendant={activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined} + onKeyDown={handleInputKey} + /> +
+ void navigate(home())}>~ + void navigate(pickerRoot(root()) || root())}> + {language.t("dialog.directory.root")} + + void navigate(pickerParent(root()))}> + {language.t("dialog.directory.parent")} + +
+ 0}> +
+ + {(suggestion, index) => ( + + )} + +
+
+
+
{ + const scroller = tree + ?.getFileTreeContainer() + ?.shadowRoot?.querySelector("[data-file-tree-virtualized-scroll]") + if (!scroller) return + const next = nextTreeScrollTop(scroller.scrollTop, event.deltaY, scroller.scrollHeight, scroller.clientHeight) + if (next === scroller.scrollTop) return + event.preventDefault() + scroller.scrollTop = next + scroller.dispatchEvent(new Event("scroll")) + }} + > +
{language.t("common.loading")}
+ +
{language.t("dialog.directory.readError")}
+
+
+
{policy.result(root(), selected(), rootValid())}
+
+ + dialog.close()}>{language.t("common.cancel")} + + {action[policy.action]} + + +
+ ) +} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 4eb5ba4b588..7dd1acc0567 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" -import { ServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" +import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" interface DialogSelectDirectoryProps { title?: string @@ -24,89 +23,9 @@ type Row = { group: "recent" | "folders" } -function cleanInput(value: string) { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() -} - -function normalizePath(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") -} - -function normalizeDriveRoot(input: string) { - const v = normalizePath(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v -} - -function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") -} - -function joinPath(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r -} - -function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" -} - -function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) -} - -function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const -} - -function tildeOf(absolute: string, home: string) { - const full = trimTrailing(absolute) - if (!home) return "" - - const hn = trimTrailing(home) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" -} - -function displayPath(path: string, input: string, home: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full - return tildeOf(full, home) || full -} - function toRow(absolute: string, home: string, group: Row["group"]): Row { - const full = trimTrailing(absolute) - const tilde = tildeOf(full, home) + const full = displayPickerPath(absolute, "", "") + const tilde = displayPickerPath(full, "~", home) const withSlash = (value: string) => { if (!value) return "" if (value.endsWith("/")) return value @@ -128,120 +47,6 @@ function uniqueRows(rows: Row[]) { }) } -function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) { - const cache = new Map>>() - let current = 0 - - const scoped = (value: string) => { - const base = args.start() - if (!base) return - - const raw = normalizeDriveRoot(value) - if (!raw) return { directory: trimTrailing(base), path: "" } - - const h = args.home() - if (raw === "~") return { directory: trimTrailing(h || base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } - - const root = rootOf(raw) - if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } - return { directory: trimTrailing(base), path: raw } - } - - const dirs = async (dir: string) => { - const key = trimTrailing(dir) - const existing = cache.get(key) - if (existing) return existing - - const request = args.sdk.client.file - .list({ directory: key, path: "" }) - .then((x) => x.data ?? []) - .catch(() => []) - .then((nodes) => - nodes - .filter((n) => n.type === "directory") - .map((n) => ({ - name: n.name, - absolute: trimTrailing(normalizeDriveRoot(n.absolute)), - })), - ) - - cache.set(key, request) - return request - } - - const match = async (dir: string, query: string, limit: number) => { - const items = await dirs(dir) - if (!query) return items.slice(0, limit).map((x) => x.absolute) - return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) - } - - return async (filter: string) => { - const token = ++current - const active = () => token === current - - const value = cleanInput(filter) - const scopedInput = scoped(value) - if (!scopedInput) return [] as string[] - - const raw = normalizeDriveRoot(value) - const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) - - const find = () => - args.sdk.client.find - .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) - .then((x) => x.data ?? []) - .catch(() => []) - - if (!isPath) { - const results = await find() - if (!active()) return [] - return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) - } - - const segments = query.replace(/^\/+/, "").split("/") - const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".") - const tail = segments[segments.length - 1] ?? "" - - const cap = 12 - const branch = 4 - let paths = [scopedInput.directory] - for (const part of head) { - if (!active()) return [] - if (part === "..") { - paths = paths.map(parentOf) - continue - } - - const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() - if (!active()) return [] - paths = Array.from(new Set(next)).slice(0, cap) - if (paths.length === 0) return [] as string[] - } - - const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - if (!active()) return [] - const deduped = Array.from(new Set(out)) - const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" - const expand = !raw.endsWith("/") - if (!expand || !tail) { - const items = base ? Array.from(new Set([base, ...deduped])) : deduped - return items.slice(0, 50) - } - - const needle = tail.toLowerCase() - const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) - const target = exact[0] - if (!target) return deduped.slice(0, 50) - - const children = await match(target, "", 30) - if (!active()) return [] - const items = Array.from(new Set([...deduped, ...children])) - return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) - } -} - export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const global = useGlobal() const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) @@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, ) - const directories = useDirectorySearch({ + const directories = createDirectorySearch({ sdk, home, - start, + base: start, }) const recentProjects = createMemo(() => { @@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") } ref={(r) => (list = r)} - onFilter={(value) => setFilter(cleanInput(value))} + onFilter={(value) => setFilter(cleanPickerInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = displayPath(item.absolute, filter(), home()) + const value = displayPickerPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = displayPath(item.absolute, filter(), home()) + const path = displayPickerPath(item.absolute, filter(), home()) if (path === "~") { return (
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 272d3390179..444cb37f8ac 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list" import { base64Encode } from "@opencode-ai/core/util/encode" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { useNavigate } from "@solidjs/router" -import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" +import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" +const DialogSelectFileV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) + type EntryType = "command" | "file" | "session" type Entry = { @@ -264,6 +271,9 @@ function createSessionEntries(props: { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { const command = useCommand() const language = useLanguage() + const platform = usePlatform() + const server = useServer() + const settings = useSettings() const layout = useLayout() const file = useFile() const dialog = useDialog() @@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil state.cleanup?.() }) + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) { + return ( + { + if (typeof result !== "string") return + open(result) + }} + /> + ) + } + return ( { + expect( + treeEntries("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + ]), + ).toEqual(["src/components/", "src/index.ts"]) +}) + +test("maps Pierre paths back to the selected server root", () => { + expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components") + expect(absoluteTreePath("C:/", "")).toBe("C:/") + expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md") + expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md") +}) + +test("includes files only when the picker selects files", () => { + const nodes = [ + { name: "components", type: "directory" as const }, + { name: "index.ts", type: "file" as const }, + ] + expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"]) + expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"]) +}) + +test("includes files in file autocomplete while preserving directory navigation", () => { + const nodes = [ + { name: "src", absolute: "/repo/src", type: "directory" as const }, + { name: "README.md", absolute: "/repo/README.md", type: "file" as const }, + ] + expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]]) + expect(pickerSearchEntries(nodes, "file")).toEqual(nodes) +}) + +test("centralizes file and directory selection policy", () => { + const file = pickerMode("file", "/repo") + expect(file.includeFiles).toBeTrue() + expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts") + expect(file.selection("/repo", "src/")).toBeUndefined() + expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts") + expect(file.selection("/tmp", "example.txt")).toBeUndefined() + expect(file.navigation("/repo/src")).toBe("/repo/src") + expect(file.navigation("/tmp")).toBeUndefined() + + const directory = pickerMode("directory") + expect(directory.includeFiles).toBeFalse() + expect(directory.selection("/repo", "src/")).toBe("/repo/src") + expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos") + expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo") + expect(directory.navigation("/tmp")).toBe("/tmp") + expect(directory.result("/repo", "")).toBe("/repo") + expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke") + expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo") + expect(directory.result("/repo", "", false)).toBeUndefined() +}) + +test("accepts mutations only from the active navigation", () => { + expect(activeTreeNavigation(3, 3)).toBeTrue() + expect(activeTreeNavigation(2, 3)).toBeFalse() +}) + +test("preserves POSIX case while matching Windows drives case-insensitively", () => { + expect(treePathWithin("/repo", "/Repo")).toBeFalse() + expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue() + expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue() + expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe( + "src/file.ts", + ) + expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse() + expect(treePathWithin("/", "/src")).toBeTrue() + expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts") + expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts") +}) + +test("displays paths using the selected server path format", () => { + expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos") + expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos") +}) + +test("treats the server share prefix as the UNC root", () => { + expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share") + expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share") + expect(pickerParent("//Server/Share")).toBe("//Server/Share") + expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share") +}) + +test("resolves relative input against the current picker root", () => { + expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src") + expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other") + expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config") + expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src") +}) + +test("exposes autocomplete results only for their source query", () => { + const result = { query: "/repo/src", items: ["/repo/src/index.ts"] } + expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items) + expect(currentPickerSuggestions(result, "/repo/test")).toEqual([]) +}) + +test("scopes file autocomplete to the current browser root", () => { + expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in") + expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op") +}) + +test("resolves directory autocomplete from the current browser root", async () => { + const directories: string[] = [] + const sdk = { + client: { + find: { + files: (input: { directory: string }) => { + directories.push(input.directory) + return Promise.resolve({ data: [] }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + let base = "/repo" + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) + + await search("components") + base = "/repo/src" + await search("components") + + expect(directories).toEqual(["/repo", "/repo/src"]) +}) + +test("identifies the next directory level to preload", () => { + expect( + preloadTreeDirectories("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + { name: "utils", type: "directory" }, + ]), + ).toEqual(["src/components/", "src/utils/"]) +}) + +test("advances preloading once for every expanded directory", () => { + const advanced = new Set() + expect(advanceTreePreload(advanced, "")).toBeTrue() + expect(advanceTreePreload(advanced, "")).toBeFalse() + expect(advanceTreePreload(advanced, "repos/")).toBeTrue() +}) + +test("clamps bridged tree wheel scrolling", () => { + expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140) + expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0) + expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300) +}) + +test("wraps autocomplete keyboard navigation", () => { + expect(nextSuggestionIndex(-1, 1, 4)).toBe(0) + expect(nextSuggestionIndex(3, 1, 4)).toBe(0) + expect(nextSuggestionIndex(0, -1, 4)).toBe(3) + expect(nextSuggestionIndex(0, 1, 0)).toBe(-1) +}) + +test("returns absolute directories and relative files", () => { + expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src") + expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined() +}) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts new file mode 100644 index 00000000000..af84e9ae9f5 --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.ts @@ -0,0 +1,331 @@ +export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + const prefix = parent.replace(/^\/+|\/+$/g, "") + return nodes.map((node) => { + const path = prefix ? `${prefix}/${node.name}` : node.name + return node.type === "directory" ? path + "/" : path + }) +} + +export function pickerTreeEntries( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, + mode: "directory" | "file", +) { + return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes) +} + +export function pickerSearchEntries( + nodes: readonly T[], + mode: "directory" | "file", +) { + return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes] +} + +export function pickerMode(mode: "directory" | "file", base?: string) { + if (mode === "file") { + return { + includeFiles: true, + action: "file" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries(parent, nodes) + }, + navigation(path: string) { + return treePathWithin(base, path) ? path : undefined + }, + result(root: string, selected: string) { + return selected || undefined + }, + selection(root: string, path: string) { + if (!treePathWithin(base, root)) return + return selectedTreePath(root, path, "file", base) + }, + } + } + return { + includeFiles: false, + action: "directory" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) + }, + navigation(path: string) { + return path + }, + result(root: string, selected: string, valid = true) { + if (!valid) return + return selected || (root ? nativePickerPath(root) : undefined) + }, + selection(root: string, path: string) { + return selectedTreePath(root, path, "directory") + }, + } +} + +export function pickerFileSearchQuery(root: string, input: string, home: string) { + const value = input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") + const base = root.replace(/\\/g, "/").replace(/\/+$/, "") + if (value === base) return "" + if (value.startsWith(base + "/")) return value.slice(base.length + 1) + return value +} + +export function pickerAbsoluteInput(input: string, home: string, current: string) { + const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home)) + const absolute = pickerRoot(value) ? value : joinPickerPath(current, value) + return canonicalPickerPath(absolute) +} + +export function treePathWithin(base: string | undefined, path: string) { + return pickerRelativePath(base, path) !== undefined +} + +export function canonicalPickerPath(path: string) { + const value = normalizePickerDrive(path) + const root = pickerRoot(value) + const parts = value.slice(root.length).split("/") + const resolved = parts.reduce((output, part) => { + if (!part || part === ".") return output + if (part === "..") { + output.pop() + return output + } + output.push(part) + return output + }, []) + return joinPickerPath(root, resolved.join("/")) +} + +export function pickerRelativePath(base: string | undefined, path: string) { + if (!base) return + const rootPath = canonicalPickerPath(base) + const targetPath = canonicalPickerPath(path) + const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//") + const root = insensitive ? rootPath.toLowerCase() : rootPath + const target = insensitive ? targetPath.toLowerCase() : targetPath + if (target === root) return "" + const prefix = root.endsWith("/") ? root : root + "/" + if (!target.startsWith(prefix)) return + return targetPath.slice(prefix.length) +} + +export function currentPickerSuggestions( + result: { query: string; items: readonly T[] } | undefined, + query: string, +) { + if (result?.query !== query) return [] + return result.items +} + +export function preloadTreeDirectories( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, +) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) +} + +export function advanceTreePreload(advanced: Set, path: string) { + if (advanced.has(path)) return false + advanced.add(path) + return true +} + +export function activeTreeNavigation(request: number, current: number) { + return request === current +} + +export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) { + return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta)) +} + +export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) { + if (count === 0) return -1 + return (current + delta + count) % count +} + +export function absoluteTreePath(root: string, path: string) { + const base = trimPickerPath(root) + const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") + if (!relative) return base || "/" + if (!base || base === "/") return "/" + relative + if (base.endsWith("/")) return base + relative + return `${base}/${relative}` +} + +export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) { + const directory = path.endsWith("/") + if (mode === "file") { + if (directory) return + if (!base) return path + const absolute = absoluteTreePath(root, path) + return pickerRelativePath(base, absolute) + } + return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined +} + +export function nativePickerPath(path: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") + return value +} +import { getFilename } from "@opencode-ai/core/util/path" +import fuzzysort from "fuzzysort" +import { ServerSDK } from "@/context/server-sdk" + +export function cleanPickerInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} + +export function normalizePickerPath(input: string) { + const value = input.replaceAll("\\", "/") + if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/") + return value.replace(/\/+/g, "/") +} + +export function normalizePickerDrive(input: string) { + const value = normalizePickerPath(input) + if (/^[A-Za-z]:$/.test(value)) return value + "/" + return value +} + +export function trimPickerPath(input: string) { + const value = normalizePickerDrive(input) + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + return value.replace(/\/+$/, "") +} + +export function joinPickerPath(base: string | undefined, relative: string) { + const root = trimPickerPath(base ?? "") + const path = trimPickerPath(relative).replace(/^\/+/, "") + if (!root) return path + if (!path) return root + if (root.endsWith("/")) return root + path + return root + "/" + path +} + +export function pickerRoot(input: string) { + const value = normalizePickerDrive(input) + if (value.startsWith("//")) { + const [server, share] = value.slice(2).split("/") + if (server && share) return `//${server}/${share}` + return "//" + } + if (value.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3) + return "" +} + +export function pickerParent(input: string) { + const value = trimPickerPath(input) + const root = pickerRoot(value) + if (value === root) return value + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + const index = value.lastIndexOf("/") + if (index < root.length) return root + if (index <= 0) return "/" + if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3) + return value.slice(0, index) +} + +function pickerTilde(absolute: string, home: string) { + const path = trimPickerPath(absolute) + if (!home) return "" + const root = trimPickerPath(home) + if (/^[A-Za-z]:\//.test(root)) return "" + if (path === root) return "~" + if (path.startsWith(root + "/")) return "~" + path.slice(root.length) + return "" +} + +export function displayPickerPath(path: string, input: string, home: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\") + return pickerTilde(value, home) || value +} + +export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { + const cache = new Map>>() + let current = 0 + + const scoped = (value: string) => { + const base = args.base() + if (!base) return + const raw = normalizePickerDrive(value) + if (!raw) return { directory: trimPickerPath(base), path: "" } + const home = args.home() + if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } + return { directory: trimPickerPath(base), path: raw } + } + + const directories = async (directory: string) => { + const key = trimPickerPath(directory) + const existing = cache.get(key) + if (existing) return existing + const request = args.sdk.client.file + .list({ directory: key, path: "" }) + .then((result) => result.data ?? []) + .catch(() => []) + .then((nodes) => + nodes + .filter((node) => node.type === "directory") + .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + ) + cache.set(key, request) + return request + } + + const match = async (directory: string, query: string, limit: number) => { + const items = await directories(directory) + if (!query) return items.slice(0, limit).map((item) => item.absolute) + return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute) + } + + return async (filter: string) => { + const token = ++current + const active = () => token === current + const value = cleanPickerInput(filter) + const input = scoped(value) + if (!input) return [] as string[] + const raw = normalizePickerDrive(value) + const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") + const query = normalizePickerDrive(input.path) + if (!pathInput) { + const results = await args.sdk.client.find + .files({ directory: input.directory, query, type: "directory", limit: 50 }) + .then((result) => result.data ?? []) + .catch(() => []) + if (!active()) return [] + return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + } + const segments = query.replace(/^\/+/, "").split("/") + const head = segments.slice(0, -1).filter((part) => part && part !== ".") + const tail = segments.at(-1) ?? "" + let paths = [input.directory] + for (const part of head) { + if (!active()) return [] + if (part === "..") { + paths = paths.map(pickerParent) + continue + } + paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12) + if (!active() || paths.length === 0) return [] + } + const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat())) + if (!active()) return [] + const base = raw.startsWith("~") ? trimPickerPath(input.directory) : "" + if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50) + const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase()) + if (!target) return matches.slice(0, 50) + const children = await match(target, "", 30) + if (!active()) return [] + return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50) + } +} diff --git a/packages/app/src/components/directory-picker.tsx b/packages/app/src/components/directory-picker.tsx index e0ee1cec390..31b15b7a6eb 100644 --- a/packages/app/src/components/directory-picker.tsx +++ b/packages/app/src/components/directory-picker.tsx @@ -1,9 +1,15 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ServerConnection } from "@/context/server" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { lazy } from "solid-js" import { DialogSelectDirectory } from "./dialog-select-directory" import { directoryPickerKind } from "./directory-picker-policy" +const DialogSelectDirectoryV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) + type DirectoryPickerInput = { server: ServerConnection.Any title?: string @@ -13,6 +19,7 @@ type DirectoryPickerInput = { export function useDirectoryPicker() { const platform = usePlatform() + const settings = useSettings() const dialog = useDialog() return (input: DirectoryPickerInput) => { @@ -21,9 +28,18 @@ export function useDirectoryPicker() { return } - dialog.show( - () => , - () => input.onSelect(null), - ) + let selected = false + const onSelect = (result: string | string[] | null) => { + selected = result !== null + input.onSelect(result) + } + const cancel = () => { + if (!selected) input.onSelect(null) + } + if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + dialog.show(() => , cancel) + return + } + dialog.show(() => , cancel) } } diff --git a/packages/app/src/components/pierre-tree.test.ts b/packages/app/src/components/pierre-tree.test.ts new file mode 100644 index 00000000000..7bd40a11d0e --- /dev/null +++ b/packages/app/src/components/pierre-tree.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees" + +test("reports directory expansion changes", () => { + const changes: Array<{ path: string; expanded: boolean }> = [] + const tree = new FileTree({ + paths: ["src/"], + onExpansionChange: (change) => changes.push(change), + }) + + const src = tree.getItem("src/") + if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory") + const directory = src as FileTreeDirectoryHandle + + directory.expand() + directory.collapse() + + expect(changes).toEqual([ + { path: "src/", expanded: true }, + { path: "src/", expanded: false }, + ]) + tree.cleanUp() +}) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index cbec31d522f..8d40953ff2f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1157,6 +1157,7 @@ export const PromptInput: Component = (props) => { }, addPart, readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, }) const fileAttachmentInput = () => ( diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 37f8b9143f6..3087c774b76 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -33,6 +33,7 @@ type PromptAttachmentsInput = { focusEditor: () => void addPart: (part: ContentPart) => boolean readClipboardImage?: () => Promise + getPathForFile?: (file: File) => string } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -63,6 +64,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { type: "image", id: uuid(), filename: file.name, + sourcePath: input.getPathForFile?.(file) || undefined, mime, dataUrl: url, } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 06c37733100..099200f23f1 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -75,6 +75,31 @@ describe("buildRequestParts", () => { expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"]) }) + test("preserves an external attachment source path for the model", () => { + const result = buildRequestParts({ + prompt: [], + context: [], + images: [ + { + type: "image", + id: "img_external", + filename: "opencode.global.dat", + sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat", + mime: "text/plain", + dataUrl: "data:text/plain;base64,AAA", + }, + ], + text: "inspect this", + messageID: "msg_external", + sessionID: "ses_external", + sessionDirectory: "C:\\Repos\\sst\\opencode", + }) + + expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe( + "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat", + ) + }) + test("deduplicates context files when prompt already includes same path", () => { const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 98771aedd19..043425356a2 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { type: "file", mime: attachment.mime, url: attachment.dataUrl, - filename: attachment.filename, + filename: attachment.sourcePath ?? attachment.filename, } satisfies PromptRequestPart }) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index f137c528dc6..de12e8c3f66 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -55,6 +55,9 @@ type PlatformBase = { onFile: (file: File) => Promise, ): Promise + /** Resolve the native source path for a desktop File. */ + getPathForFile?(file: File): string + /** Open a native save file picker dialog (desktop only) */ saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index f26ac767a0a..4a62c2a8d8d 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -33,6 +33,7 @@ export interface ImageAttachmentPart { type: "image" id: string filename: string + sourcePath?: string mime: string dataUrl: string } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 5d124024a3b..6f30244033c 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -281,6 +281,11 @@ export const dict = { "dialog.fork.empty": "لا توجد رسائل للتفرع منها", "dialog.directory.search.placeholder": "البحث في المجلدات", "dialog.directory.empty": "لم يتم العثور على مجلدات", + "dialog.directory.action.selectFile": "اختيار ملف", + "dialog.directory.action.selectFolder": "اختيار مجلد", + "dialog.directory.root": "الجذر", + "dialog.directory.parent": "المجلد الأصل", + "dialog.directory.readError": "تعذرت قراءة هذا المجلد", "dialog.server.title": "الخوادم", "dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.", "dialog.server.search.placeholder": "البحث في الخوادم", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 7c6965e330c..62b52576bce 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -281,6 +281,11 @@ export const dict = { "dialog.fork.empty": "Nenhuma mensagem para bifurcar", "dialog.directory.search.placeholder": "Buscar pastas", "dialog.directory.empty": "Nenhuma pasta encontrada", + "dialog.directory.action.selectFile": "Selecionar arquivo", + "dialog.directory.action.selectFolder": "Selecionar pasta", + "dialog.directory.root": "Raiz", + "dialog.directory.parent": "Pasta superior", + "dialog.directory.readError": "Não foi possível ler esta pasta", "dialog.server.title": "Servidores", "dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.", "dialog.server.search.placeholder": "Buscar servidores", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 201b6ed81c1..5bb0af6bb67 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -307,6 +307,11 @@ export const dict = { "dialog.directory.search.placeholder": "Pretraži foldere", "dialog.directory.empty": "Nema pronađenih foldera", + "dialog.directory.action.selectFile": "Odaberi datoteku", + "dialog.directory.action.selectFolder": "Odaberi folder", + "dialog.directory.root": "Korijen", + "dialog.directory.parent": "Nadređeni folder", + "dialog.directory.readError": "Nije moguće pročitati ovaj folder", "dialog.server.title": "Serveri", "dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index d52aca823ee..5351fbaeabd 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -305,6 +305,11 @@ export const dict = { "dialog.directory.search.placeholder": "Søg mapper", "dialog.directory.empty": "Ingen mapper fundet", + "dialog.directory.action.selectFile": "Vælg fil", + "dialog.directory.action.selectFolder": "Vælg mappe", + "dialog.directory.root": "Rod", + "dialog.directory.parent": "Overordnet", + "dialog.directory.readError": "Denne mappe kan ikke læses", "dialog.server.title": "Servere", "dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4d5a6499028..2f4b36d4c60 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -287,6 +287,11 @@ export const dict = { "dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden", "dialog.directory.search.placeholder": "Ordner durchsuchen", "dialog.directory.empty": "Keine Ordner gefunden", + "dialog.directory.action.selectFile": "Datei auswählen", + "dialog.directory.action.selectFolder": "Ordner auswählen", + "dialog.directory.root": "Stammverzeichnis", + "dialog.directory.parent": "Übergeordnet", + "dialog.directory.readError": "Dieser Ordner kann nicht gelesen werden", "dialog.server.title": "Server", "dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.", "dialog.server.search.placeholder": "Server durchsuchen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 403addcb241..1ba0c3d230a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -313,6 +313,11 @@ export const dict = { "dialog.directory.search.placeholder": "Search folders", "dialog.directory.empty": "No folders found", + "dialog.directory.action.selectFile": "Select file", + "dialog.directory.action.selectFolder": "Select folder", + "dialog.directory.root": "Root", + "dialog.directory.parent": "Parent", + "dialog.directory.readError": "Unable to read this folder", "app.server.unreachable": "Could not reach {{server}}", "app.server.retrying": "Retrying automatically...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index b71fe03e878..1745f216fbd 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -306,6 +306,11 @@ export const dict = { "dialog.directory.search.placeholder": "Buscar carpetas", "dialog.directory.empty": "No se encontraron carpetas", + "dialog.directory.action.selectFile": "Seleccionar archivo", + "dialog.directory.action.selectFolder": "Seleccionar carpeta", + "dialog.directory.root": "Raíz", + "dialog.directory.parent": "Superior", + "dialog.directory.readError": "No se puede leer esta carpeta", "dialog.server.title": "Servidores", "dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 385dbdddefc..1e38d85d735 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -282,6 +282,11 @@ export const dict = { "dialog.fork.empty": "Aucun message à partir duquel bifurquer", "dialog.directory.search.placeholder": "Rechercher des dossiers", "dialog.directory.empty": "Aucun dossier trouvé", + "dialog.directory.action.selectFile": "Sélectionner le fichier", + "dialog.directory.action.selectFolder": "Sélectionner le dossier", + "dialog.directory.root": "Racine", + "dialog.directory.parent": "Parent", + "dialog.directory.readError": "Impossible de lire ce dossier", "dialog.server.title": "Serveurs", "dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.", "dialog.server.search.placeholder": "Rechercher des serveurs", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 34ac398bd5e..c8ae0539ba0 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -280,6 +280,11 @@ export const dict = { "dialog.fork.empty": "フォーク元のメッセージがありません", "dialog.directory.search.placeholder": "フォルダを検索", "dialog.directory.empty": "フォルダが見つかりません", + "dialog.directory.action.selectFile": "ファイルを選択", + "dialog.directory.action.selectFolder": "フォルダを選択", + "dialog.directory.root": "ルート", + "dialog.directory.parent": "親フォルダ", + "dialog.directory.readError": "このフォルダを読み取れません", "dialog.server.title": "サーバー", "dialog.server.description": "このアプリが接続するOpenCodeサーバーを切り替えます。", "dialog.server.search.placeholder": "サーバーを検索", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index f1926f3dd28..77aedaa2046 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -280,6 +280,11 @@ export const dict = { "dialog.fork.empty": "분기할 메시지 없음", "dialog.directory.search.placeholder": "폴더 검색", "dialog.directory.empty": "폴더 없음", + "dialog.directory.action.selectFile": "파일 선택", + "dialog.directory.action.selectFolder": "폴더 선택", + "dialog.directory.root": "루트", + "dialog.directory.parent": "상위 폴더", + "dialog.directory.readError": "이 폴더를 읽을 수 없습니다", "dialog.server.title": "서버", "dialog.server.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.", "dialog.server.search.placeholder": "서버 검색", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index b4adebdb7ce..8f35d235a05 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -309,6 +309,11 @@ export const dict = { "dialog.directory.search.placeholder": "Søk etter mapper", "dialog.directory.empty": "Ingen mapper funnet", + "dialog.directory.action.selectFile": "Velg fil", + "dialog.directory.action.selectFolder": "Velg mappe", + "dialog.directory.root": "Rot", + "dialog.directory.parent": "Overordnet", + "dialog.directory.readError": "Kan ikke lese denne mappen", "dialog.server.title": "Servere", "dialog.server.description": "Bytt hvilken OpenCode-server denne appen kobler til.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 02637e574f1..52752613958 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -282,6 +282,11 @@ export const dict = { "dialog.fork.empty": "Brak wiadomości do rozwidlenia", "dialog.directory.search.placeholder": "Szukaj folderów", "dialog.directory.empty": "Nie znaleziono folderów", + "dialog.directory.action.selectFile": "Wybierz plik", + "dialog.directory.action.selectFolder": "Wybierz folder", + "dialog.directory.root": "Katalog główny", + "dialog.directory.parent": "Nadrzędny", + "dialog.directory.readError": "Nie można odczytać tego folderu", "dialog.server.title": "Serwery", "dialog.server.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.", "dialog.server.search.placeholder": "Szukaj serwerów", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index a965acf278d..d05aac201f7 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -306,6 +306,11 @@ export const dict = { "dialog.directory.search.placeholder": "Поиск папок", "dialog.directory.empty": "Папки не найдены", + "dialog.directory.action.selectFile": "Выбрать файл", + "dialog.directory.action.selectFolder": "Выбрать папку", + "dialog.directory.root": "Корень", + "dialog.directory.parent": "Родительская папка", + "dialog.directory.readError": "Не удалось прочитать эту папку", "dialog.server.title": "Серверы", "dialog.server.description": "Переключите сервер OpenCode к которому подключается приложение.", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 00df86a34d0..4d024da66b9 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -306,6 +306,11 @@ export const dict = { "dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์", "dialog.directory.empty": "ไม่พบโฟลเดอร์", + "dialog.directory.action.selectFile": "เลือกไฟล์", + "dialog.directory.action.selectFolder": "เลือกโฟลเดอร์", + "dialog.directory.root": "ราก", + "dialog.directory.parent": "โฟลเดอร์หลัก", + "dialog.directory.readError": "ไม่สามารถอ่านโฟลเดอร์นี้ได้", "dialog.server.title": "เซิร์ฟเวอร์", "dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 27a6e03e367..53fdf071d2d 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -311,6 +311,11 @@ export const dict = { "dialog.directory.search.placeholder": "Klasör ara", "dialog.directory.empty": "Klasör bulunamadı", + "dialog.directory.action.selectFile": "Dosya seç", + "dialog.directory.action.selectFolder": "Klasör seç", + "dialog.directory.root": "Kök", + "dialog.directory.parent": "Üst klasör", + "dialog.directory.readError": "Bu klasör okunamıyor", "dialog.server.title": "Sunucular", "dialog.server.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 13863725c67..a3b49ac6ab6 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -313,6 +313,11 @@ export const dict = { "dialog.directory.search.placeholder": "Пошук папок", "dialog.directory.empty": "Папок не знайдено", + "dialog.directory.action.selectFile": "Вибрати файл", + "dialog.directory.action.selectFolder": "Вибрати папку", + "dialog.directory.root": "Корінь", + "dialog.directory.parent": "Батьківська папка", + "dialog.directory.readError": "Не вдалося прочитати цю папку", "app.server.unreachable": "Не вдалося досягти {{server}}", "app.server.retrying": "Автоматичне повторення...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 663f2ccf768..541bce13886 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -326,6 +326,11 @@ export const dict = { "dialog.directory.search.placeholder": "搜索文件夹", "dialog.directory.empty": "未找到文件夹", + "dialog.directory.action.selectFile": "选择文件", + "dialog.directory.action.selectFolder": "选择文件夹", + "dialog.directory.root": "根目录", + "dialog.directory.parent": "上级目录", + "dialog.directory.readError": "无法读取此文件夹", "dialog.server.title": "服务器", "dialog.server.description": "切换此应用连接的 OpenCode 服务器。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index edd6f7bc064..75fb452bc0a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -306,6 +306,11 @@ export const dict = { "dialog.directory.search.placeholder": "搜尋資料夾", "dialog.directory.empty": "找不到資料夾", + "dialog.directory.action.selectFile": "選擇檔案", + "dialog.directory.action.selectFolder": "選擇資料夾", + "dialog.directory.root": "根目錄", + "dialog.directory.parent": "上層目錄", + "dialog.directory.readError": "無法讀取此資料夾", "dialog.server.title": "伺服器", "dialog.server.description": "切換此應用程式連線的 OpenCode 伺服器。", diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index ab8e0a93abe..a5cb1632eb9 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron" +import { contextBridge, ipcRenderer, webUtils } from "electron" import type { ElectronAPI, WslServersEvent } from "./types" import type { UpdaterState } from "@opencode-ai/app/updater" @@ -88,6 +88,7 @@ const api: ElectronAPI = { openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path), releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token), + getPathForFile: (file) => webUtils.getPathForFile(file), saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), openLink: (url) => ipcRenderer.send("open-link", url), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index f3957b89cdf..6bb2e029ca2 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -78,6 +78,7 @@ export type ElectronAPI = { }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null> readPickedFile: (token: string, path: string) => Promise releasePickedFiles: (token: string) => Promise + getPathForFile: (file: File) => string saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void openPath: (path: string, app?: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 0118e1e2f19..c9bd050e709 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -78,6 +78,7 @@ const listenForDeepLinks = () => { } const createPlatform = (): Platform => { + const attachmentPaths = new WeakMap() const os = (() => { const ua = navigator.userAgent if (ua.includes("Mac")) return "macos" @@ -153,13 +154,19 @@ const createPlatform = (): Platform => { if (!result) return try { for (const file of result.files) { - await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name)) + const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name) + attachmentPaths.set(selected, file.path) + await onFile(selected) } } finally { await window.api.releasePickedFiles(result.token) } }, + getPathForFile(file) { + return attachmentPaths.get(file) ?? window.api.getPathForFile(file) + }, + async saveFilePickerDialog(opts) { return window.api.saveFilePicker({ title: opts?.title ?? t("desktop.dialog.saveFile"), diff --git a/patches/@pierre%2Ftrees@1.0.0-beta.4.patch b/patches/@pierre%2Ftrees@1.0.0-beta.4.patch new file mode 100644 index 00000000000..b9db9cbf790 --- /dev/null +++ b/patches/@pierre%2Ftrees@1.0.0-beta.4.patch @@ -0,0 +1,107 @@ +diff --git a/dist/model/FileTreeController.js b/dist/model/FileTreeController.js +index 37c5d1ffedd5aff0258dec41b1b5cedb240aa3a6..365c42ef4c824bfd13d14f0d09226929af625c73 100644 +--- a/dist/model/FileTreeController.js ++++ b/dist/model/FileTreeController.js +@@ -1052,8 +1052,8 @@ var FileTreeController = class { + this.#setExpandedPaths(expandedPaths); + return focusCandidate ?? this.#focusedPath; + } +- #emit() { +- for (const listener of this.#listeners) listener(); ++ #emit(event) { ++ for (const listener of this.#listeners) listener(event); + } + #emitMutation(event) { + this.#mutationListeners.get(event.operation)?.forEach((listener) => { +@@ -1149,7 +1149,7 @@ var FileTreeController = class { + const searchFocusCandidate = this.#searchValue != null && this.#searchValue.length > 0 ? this.#refreshActiveSearchState() : this.#searchValue === "" ? this.#focusedPath : focusPathCandidate; + const shouldBuildFullProjection = this.#searchValue != null || event.operation !== "expand" && event.operation !== "collapse"; + this.#rebuildVisibleProjection(searchFocusCandidate, shouldBuildFullProjection); +- this.#emit(); ++ this.#emit(event); + const mutationEvent = toTreesMutationEvent(event); + if (mutationEvent != null) this.#emitMutation(mutationEvent); + }); +diff --git a/dist/model/internalTypes.d.ts b/dist/model/internalTypes.d.ts +index 5b0fdd5a3b483439ae256795f3dad5a32182ab78..073c6f21edc20ce5bd44b122fe02b83de802b887 100644 +--- a/dist/model/internalTypes.d.ts ++++ b/dist/model/internalTypes.d.ts +@@ -4,7 +4,10 @@ import { FileTreeCompositionOptions, FileTreePublicId, FileTreeRenderOptions, Fi + import { FileTreeController } from "./FileTreeController.js"; + + //#region src/model/internalTypes.d.ts +-type FileTreeControllerListener = () => void; ++type FileTreeControllerListener = (event?: { ++ operation: 'expand' | 'collapse'; ++ path: string; ++}) => void; + interface FileTreeStickyRowCandidate { + row: FileTreeVisibleRow; + subtreeEndIndex: number; +diff --git a/dist/model/publicTypes.d.ts b/dist/model/publicTypes.d.ts +index 84a7e2d14f67c1aad8230f19a3426ab7403f378e..752e262e6d2d8a836931b7b83a93d89a75e6209c 100644 +--- a/dist/model/publicTypes.d.ts ++++ b/dist/model/publicTypes.d.ts +@@ -167,6 +167,10 @@ type FileTreeOptionSurface = FileTreeRenderOptions & { + gitStatus?: readonly GitStatusEntry[]; + id?: string; + icons?: FileTreeIcons; ++ onExpansionChange?: (change: { ++ expanded: boolean; ++ path: FileTreePublicId; ++ }) => void; + onSelectionChange?: FileTreeSelectionChangeListener; + renderRowDecoration?: FileTreeRowDecorationRenderer; + search?: boolean; +diff --git a/dist/render/FileTree.js b/dist/render/FileTree.js +index 6db15e51b833192f79b71a15febe1612a4c185d0..36a4e6ac527595849c1db0e6bbebb475b6030b7b 100644 +--- a/dist/render/FileTree.js ++++ b/dist/render/FileTree.js +@@ -63,6 +63,7 @@ var FileTree = class { + #composition; + #controller; + #id; ++ #onExpansionChange; + #onSelectionChange; + #renderRowDecoration; + #renamingEnabled; +@@ -80,16 +81,18 @@ var FileTree = class { + #appliedUnsafeCSS; + #selectionVersion; + #selectionSubscription = null; ++ #expansionSubscription = null; + #wrapper; + #wroteHostItemHeight = false; + #wroteHostDensityFactor = false; + constructor(options) { +- const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options; ++ const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onExpansionChange, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options; + this.#composition = composition; + this.#id = createClientId(id); + this.#gitStatusState = resolveFileTreeGitStatusState(gitStatus); + this.#icons = icons; + this.#unsafeCSS = unsafeCSS; ++ this.#onExpansionChange = onExpansionChange; + this.#onSelectionChange = onSelectionChange; + this.#renderRowDecoration = renderRowDecoration; + this.#renamingEnabled = renaming != null && renaming !== false; +@@ -114,6 +117,10 @@ var FileTree = class { + this.#selectionSubscription = this.#onSelectionChange == null ? null : this.subscribe(() => { + this.#emitSelectionChange(); + }); ++ this.#expansionSubscription = this.#onExpansionChange == null ? null : this.#controller.subscribe((event) => { ++ if (event?.operation !== "expand" && event?.operation !== "collapse") return; ++ this.#onExpansionChange?.({ path: event.path, expanded: event.operation === "expand" }); ++ }); + } + unmount() { + if (this.#wrapper != null) { +@@ -133,6 +140,8 @@ var FileTree = class { + this.unmount(); + this.#selectionSubscription?.(); + this.#selectionSubscription = null; ++ this.#expansionSubscription?.(); ++ this.#expansionSubscription = null; + this.#controller.destroy(); + } + getFileTreeContainer() { From 1c2c2d6b5236cd5ec7699bd405291b55caeba168 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 16 Jun 2026 15:45:29 +0000 Subject: [PATCH 003/335] chore: generate --- bun.lock | 2 +- .../components/dialog-select-directory-v2.tsx | 50 ++++++++++++------- .../directory-picker-domain.test.ts | 4 +- .../src/components/directory-picker-domain.ts | 10 ++-- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 4b927abdb1f..8a9ce9a96cb 100644 --- a/bun.lock +++ b/bun.lock @@ -914,6 +914,7 @@ ], "patchedDependencies": { "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", @@ -923,7 +924,6 @@ "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 940e427ea98..3971ac25786 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -64,12 +64,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - () => sdk.client.path.get().then((result) => result.data).catch(() => undefined), + () => + sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") const start = createMemo( - () => props.start || sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + () => + props.start || + sync.data.path.home || + sync.data.path.directory || + fallbackPath()?.home || + fallbackPath()?.directory, ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { @@ -111,9 +120,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { if (!key) setError(true) return false } - tree?.batch( - policy.entries(key, nodes).map((item) => ({ type: "add", path: item })), - ) + tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item }))) if (preload && advanceTreePreload(advanced, key)) { void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false))) } @@ -147,9 +154,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const value = displayPickerPath(match.absolute, input(), home()) setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) if (match.type === "file") { - setSelected( - policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "", - ) + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "") setSuggestionsOpen(false) setActiveSuggestion(-1) } @@ -161,9 +166,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { return } setInput(displayPickerPath(suggestion.absolute, input(), home())) - setSelected( - policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "", - ) + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "") setSuggestionsOpen(false) setActiveSuggestion(-1) } @@ -239,7 +242,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { }, onSelectionChange(paths) { const path = paths.at(-1) - setSelected(path ? policy.selection(root(), path) ?? "" : "") + setSelected(path ? (policy.selection(root(), path) ?? "") : "") }, }) if (!container) return @@ -275,11 +278,15 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { aria-autocomplete="list" aria-expanded={suggestionsOpen()} aria-controls="directory-picker-v2-suggestions" - aria-activedescendant={activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined} + aria-activedescendant={ + activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined + } onKeyDown={handleInputKey} />
- void navigate(home())}>~ + void navigate(home())}> + ~ + void navigate(pickerRoot(root()) || root())}> {language.t("dialog.directory.root")} @@ -315,14 +322,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ?.getFileTreeContainer() ?.shadowRoot?.querySelector("[data-file-tree-virtualized-scroll]") if (!scroller) return - const next = nextTreeScrollTop(scroller.scrollTop, event.deltaY, scroller.scrollHeight, scroller.clientHeight) + const next = nextTreeScrollTop( + scroller.scrollTop, + event.deltaY, + scroller.scrollHeight, + scroller.clientHeight, + ) if (next === scroller.scrollTop) return event.preventDefault() scroller.scrollTop = next scroller.dispatchEvent(new Event("scroll")) }} > -
{language.t("common.loading")}
+ +
{language.t("common.loading")}
+
{language.t("dialog.directory.readError")}
@@ -330,7 +344,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
{policy.result(root(), selected(), rootValid())}
- dialog.close()}>{language.t("common.cancel")} + dialog.close()}> + {language.t("common.cancel")} + {action[policy.action]} diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index c6888e9e150..d28344c935a 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -86,9 +86,7 @@ test("preserves POSIX case while matching Windows drives case-insensitively", () expect(treePathWithin("/repo", "/Repo")).toBeFalse() expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue() expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue() - expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe( - "src/file.ts", - ) + expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts") expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse() expect(treePathWithin("/", "/src")).toBeTrue() expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts") diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index af84e9ae9f5..2faa8cb2e00 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -64,7 +64,10 @@ export function pickerMode(mode: "directory" | "file", base?: string) { } export function pickerFileSearchQuery(root: string, input: string, home: string) { - const value = input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") + const value = input + .replace(/\\/g, "/") + .replace(/^~(?=\/|$)/, home) + .replace(/\/+$/, "") const base = root.replace(/\\/g, "/").replace(/\/+$/, "") if (value === base) return "" if (value.startsWith(base + "/")) return value.slice(base.length + 1) @@ -110,10 +113,7 @@ export function pickerRelativePath(base: string | undefined, path: string) { return targetPath.slice(prefix.length) } -export function currentPickerSuggestions( - result: { query: string; items: readonly T[] } | undefined, - query: string, -) { +export function currentPickerSuggestions(result: { query: string; items: readonly T[] } | undefined, query: string) { if (result?.query !== query) return [] return result.items } From e7726643896fb04a17b3ceb214b0cf02d80334a5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 16 Jun 2026 15:57:58 +0000 Subject: [PATCH 004/335] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 7da0bef7c25..3b8dfa532b9 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-IFNpWdkSU71okC1AGsElgw9VYY3lw0HnCu2KRWPxWJw=", - "aarch64-linux": "sha256-MFATBt41ZxdATnTLtX+dJukWETjkzKsSM1xZ5KH+5v4=", - "aarch64-darwin": "sha256-IY7Y8orDULT7EOVgHyKdXhApFRqJMzaiEX+nl3VH9XA=", - "x86_64-darwin": "sha256-BQo3pTVN1dr47XokrBM/h1cHxgFw3UaPtlw0mXs/yD0=" + "x86_64-linux": "sha256-FMYhOcNXMhQLg1TIgmw45f6WBySCZV6pMrrMajttQ84=", + "aarch64-linux": "sha256-b3Jlln9hyG1AFZcgbNIrhpotYa4HFpWQU41uvMReWHw=", + "aarch64-darwin": "sha256-qJApAzbQz1nEufwUBhpgD2uoRfPz9cDFlveNZXFvNRM=", + "x86_64-darwin": "sha256-3NC69JtMIqqojcQrbcpEg4TzY0bv36Rr9Zg9oAD+dt8=" } } From 3b811bd0195e0f5a849920a42d5719b4feb67f95 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:53:57 +0200 Subject: [PATCH 005/335] feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331) --- bun.lock | 100 ++- bunfig.toml | 2 +- package.json | 12 +- .../session-timeline-collapse-state.spec.ts | 102 ++- .../session-timeline-context-resize.spec.ts | 18 +- .../app/e2e/smoke/session-timeline.spec.ts | 273 ++++++++ packages/app/e2e/utils/mock-server.ts | 14 +- packages/app/package.json | 7 +- .../context/global-sync/event-reducer.test.ts | 20 + .../src/context/global-sync/event-reducer.ts | 8 +- packages/app/src/context/server-sdk.test.ts | 41 +- packages/app/src/context/server-sdk.tsx | 46 +- packages/app/src/pages/layout.tsx | 4 +- packages/app/src/pages/session.tsx | 304 +++------ .../pages/session/timeline/measure.test.ts | 30 + .../app/src/pages/session/timeline/measure.ts | 5 + .../{ => timeline}/message-timeline.tsx | 640 +++++++++--------- .../src/pages/session/timeline/model.test.ts | 101 +++ .../app/src/pages/session/timeline/model.ts | 152 +++++ .../src/pages/session/timeline/projection.ts | 113 ++++ .../rows.ts} | 20 +- .../app/test-browser/solid-virtual.test.ts | 46 ++ .../opencode/test/share/share-next.test.ts | 10 +- packages/ui/package.json | 4 +- packages/ui/src/components/file.css | 4 + packages/ui/src/components/file.tsx | 2 +- .../components/markdown-code-state.test.ts | 32 + .../ui/src/components/markdown-code-state.ts | 22 + .../src/components/markdown-shiki.worker.ts | 104 +++ .../ui/src/components/markdown-stream.test.ts | 164 ++++- packages/ui/src/components/markdown-stream.ts | 93 ++- .../markdown-worker-protocol.test.ts | 77 +++ .../components/markdown-worker-protocol.ts | 48 ++ .../components/markdown-worker-queue.test.ts | 49 ++ .../src/components/markdown-worker-queue.ts | 64 ++ .../markdown-worker-transport.test.ts | 56 ++ .../components/markdown-worker-transport.ts | 41 ++ packages/ui/src/components/markdown-worker.ts | 122 ++++ packages/ui/src/components/markdown.css | 8 + packages/ui/src/components/markdown.tsx | 359 ++++++++-- packages/ui/src/components/message-part.tsx | 27 +- .../ui/src/components/scroll-view.test.ts | 37 +- packages/ui/src/components/scroll-view.tsx | 48 +- packages/ui/src/context/marked.tsx | 11 +- packages/ui/src/pierre/index.ts | 27 +- packages/ui/src/pierre/virtualizer.ts | 2 +- .../@tanstack%2Fsolid-virtual@3.13.28.patch | 45 ++ patches/@tanstack%2Fvirtual-core@3.17.0.patch | 58 ++ patches/virtua@0.49.1.patch | 93 --- 49 files changed, 2822 insertions(+), 843 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/measure.test.ts create mode 100644 packages/app/src/pages/session/timeline/measure.ts rename packages/app/src/pages/session/{ => timeline}/message-timeline.tsx (79%) create mode 100644 packages/app/src/pages/session/timeline/model.test.ts create mode 100644 packages/app/src/pages/session/timeline/model.ts create mode 100644 packages/app/src/pages/session/timeline/projection.ts rename packages/app/src/pages/session/{message-timeline.data.ts => timeline/rows.ts} (96%) create mode 100644 packages/app/test-browser/solid-virtual.test.ts create mode 100644 packages/ui/src/components/markdown-code-state.test.ts create mode 100644 packages/ui/src/components/markdown-code-state.ts create mode 100644 packages/ui/src/components/markdown-shiki.worker.ts create mode 100644 packages/ui/src/components/markdown-worker-protocol.test.ts create mode 100644 packages/ui/src/components/markdown-worker-protocol.ts create mode 100644 packages/ui/src/components/markdown-worker-queue.test.ts create mode 100644 packages/ui/src/components/markdown-worker-queue.ts create mode 100644 packages/ui/src/components/markdown-worker-transport.test.ts create mode 100644 packages/ui/src/components/markdown-worker-transport.ts create mode 100644 packages/ui/src/components/markdown-worker.ts create mode 100644 patches/@tanstack%2Fsolid-virtual@3.13.28.patch create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.0.patch delete mode 100644 patches/virtua@0.49.1.patch diff --git a/bun.lock b/bun.lock index 8a9ce9a96cb..34793cde8d4 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", @@ -66,7 +67,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", - "virtua": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", @@ -828,6 +828,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/event-listener": "2.4.5", @@ -853,7 +854,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "strip-ansi": "7.1.2", - "virtua": "catalog:", }, "devDependencies": { "@tailwindcss/vite": "catalog:", @@ -905,25 +905,26 @@ }, }, "trustedDependencies": [ - "esbuild", "tree-sitter-powershell", - "protobufjs", - "electron", "web-tree-sitter", "tree-sitter-bash", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -947,15 +948,17 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", - "@pierre/diffs": "1.1.0-beta.18", + "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", + "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -981,14 +984,13 @@ "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", - "virtua": "0.49.1", "vite": "7.1.4", "vite-plugin-solid": "2.11.10", "zod": "4.1.8", @@ -2124,9 +2126,11 @@ "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + + "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="], @@ -2332,13 +2336,17 @@ "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], @@ -2582,6 +2590,10 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -4852,7 +4864,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], @@ -5262,8 +5274,6 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], - "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], @@ -5512,6 +5522,8 @@ "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -5850,13 +5862,17 @@ "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -5966,6 +5982,8 @@ "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -6210,9 +6228,9 @@ "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6354,6 +6372,18 @@ "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -6650,6 +6680,8 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6716,6 +6748,18 @@ "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], diff --git a/bunfig.toml b/bunfig.toml index c086c75bfac..c506ff57c4b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 08f06188f86..c0dc905aae4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", + "@tanstack/solid-virtual": "3.13.28", + "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -51,7 +53,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.1.0-beta.18", + "@pierre/diffs": "1.2.10", "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", @@ -76,10 +78,9 @@ "zod": "4.1.8", "remeda": "2.26.0", "sst": "4.13.1", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", - "virtua": "0.49.1", "vite": "7.1.4", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -145,12 +146,13 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch" + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch" } } diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 8b31b8cc273..c902e7faf38 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => { const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() await expectAppVisible(wrapper) - await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first()) + const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first() + await expectAppVisible(file) await markDiffProbe(page) events.push({ @@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => { }) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) - expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + const siblingProbe = await readDiffProbe(page) + expect(siblingProbe).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) await markDiffProbe(page) events.push({ @@ -173,7 +182,72 @@ test.describe("regression: session timeline local row state", () => { await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( { timeout: 10_000 }, ) - expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + expect(await readDiffProbe(page)).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) + }) + + test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => { + const events: EventPayload[] = [] + const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("") + const after = [100, 300, 500, 700, 900].reduce( + (result, index) => result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`), + lines, + ) + const part = { + ...editPart, + state: { + ...editPart.state, + metadata: { + ...editPart.state.metadata, + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: lines, + after, + }, + }, + }, + } + await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }]) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const diff = wrapper.locator('[data-component="edit-content"]').first() + await expectAppVisible(diff) + await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500) + const samples = await wrapper.evaluate(async (element) => { + const root = element.closest(".scroll-view__viewport")! + element.scrollIntoView({ block: "start" }) + const result = [] + for (const offset of [0, 120, 240, 360, 480]) { + root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0)) + await new Promise(requestAnimationFrame) + const trigger = element.querySelector('[data-slot="collapsible-trigger"]')! + const diff = element.querySelector('[data-component="edit-content"]')! + result.push({ + offset, + trigger: trigger.getBoundingClientRect().y, + diff: diff.getBoundingClientRect().y, + bottom: element.getBoundingClientRect().bottom, + }) + } + return result + }) + + expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff) + expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true) + expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true) }) }) @@ -247,10 +321,16 @@ async function markDiffProbe(page: Page) { .evaluate((element) => { const tool = element as HTMLElement const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") if (!file) throw new Error("missing edit diff file") + if (!row) throw new Error("missing virtual timeline row") + if (!frame) throw new Error("missing timeline row frame") tool.dataset.timelineProbe = "before" file.dataset.timelineProbe = "before" + row.dataset.timelineProbe = "before" + frame.dataset.timelineProbe = "before" window.__timelineDiffProbe.reset() }) } @@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) { .evaluate((element) => { const tool = element as HTMLElement const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") return { fileMarker: file?.dataset.timelineProbe, shadowRoots: window.__timelineDiffProbe.shadowRoots(), toolMarker: tool.dataset.timelineProbe, + rowMarker: row?.dataset.timelineProbe, + rowKey: row?.dataset.timelineKey, + frameMarker: frame?.dataset.timelineProbe, } }) } @@ -300,14 +385,19 @@ function readExpanded(element: Element) { return !!content && content.getBoundingClientRect().height > 0 } -async function mockServer(page: Page, events: EventPayload[]) { +async function mockServer( + page: Page, + events: EventPayload[], + messages = [userMessage, assistantMessage], +) { await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - pageMessages: () => ({ items: [userMessage, assistantMessage] }), - events: () => events.splice(0), + pageMessages: () => ({ items: messages }), + events: () => events.splice(0, 1), + eventRetry: 16, }) } diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index d2b18275734..745dee10e53 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -32,8 +32,6 @@ test.describe("regression: session timeline context group resize", () => { const samples = await sampleExpansion(page) const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) - console.log("context resize samples", JSON.stringify(samples, null, 2)) - expect(samples[0]?.overlap).toBe(0) expect(visibleOverlap).toEqual([]) expect(samples.at(-1)?.expanded).toBe("true") @@ -115,13 +113,15 @@ async function sampleExpansion(page: Page) { let frame = 1 const tick = () => { - capture(frame, "raf") - frame += 1 - if (frame > 8) { - resolve(samples) - return - } - requestAnimationFrame(tick) + setTimeout(() => { + capture(frame, "painted") + frame += 1 + if (frame > 8) { + resolve(samples) + return + } + requestAnimationFrame(tick) + }, 0) } requestAnimationFrame(tick) }), diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 5d7425ea5bb..de5eeb20ca5 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -30,6 +30,271 @@ type SmokeWindow = Window & { test.describe("smoke: session timeline", () => { test.setTimeout(240_000) + test("keeps the visible message fixed while prepending history", async ({ page }) => { + const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + messageDelay: 3_000, + onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await pointAtTimeline(page) + const deadline = Date.now() + 120_000 + while (!requests.some((request) => request.before && request.phase === "start")) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + for (let index = 0; index < 12; index++) { + await page.mouse.wheel(0, -120) + await page.waitForTimeout(20) + } + const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"] + const positions = () => + scroller.evaluate((element, keys) => { + const top = element.getBoundingClientRect().top + return Object.fromEntries( + keys.map((key) => { + const row = element.querySelector(`[data-timeline-part-id="${key}"]`) + if (!row) throw new Error(`Missing stable timeline key: ${key}`) + return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio] + }), + ) + }, keys) + const before = await positions() + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + + await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) + await waitForTimelineStable(page) + await expect.poll(positions).toEqual(before) + }) + + test("preserves the timeline gap above the composer", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await waitForTimelineStable(page) + + const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]') + await expect(spacer).toBeVisible() + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + }) + + test("paints cached session tabs at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const last = fixture.expected.targetMessageIDs.at(-1)! + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] + const firstPaintNodes = new WeakSet() + let firstPaint = false + let removedFirstPaintNodes = 0 + let running = true + new MutationObserver((records) => { + if (!firstPaint || !running) return + records.forEach((record) => + record.removedNodes.forEach((node) => { + if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 + if (!(node instanceof Element)) return + node.querySelectorAll("*").forEach((element) => { + if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 + }) + }), + ) + }).observe(document.documentElement, { childList: true, subtree: true }) + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + .filter((id) => ids.has(id)) + const bottom = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { + firstPaint = true + root.querySelectorAll("[data-timeline-key]").forEach((row) => { + const rect = row.getBoundingClientRect() + if (rect.bottom <= view.top || rect.top >= view.bottom) return + firstPaintNodes.add(row) + row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element)) + }) + } + } + requestAnimationFrame(sample) + }, 0) + } + ;(window as Window & { + __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + }).__sessionTabPaint = { + samples, + removed: () => removedFirstPaintNodes, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some( + (sample) => sample.ids.length > 0, + ), + ) + await page.waitForTimeout(200) + const first = await page.evaluate(() => { + const probe = (window as Window & { + __sessionTabPaint?: { + samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> + removed: () => number + stop: () => void + } + }).__sessionTabPaint! + probe.stop() + return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } + }) + expect(first.first?.last).toBe(true) + expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + expect(first.removed).toBe(0) + }) + + test("paints a cold session tab at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) + await expectSessionTitle(page, fixture.expected.sourceTitle) + const last = fixture.expected.targetMessageIDs.at(-1)! + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + await page.evaluate(({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] + const sample = () => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + samples.push({ + destination: messages.some((element) => ids.has(element.dataset.messageId!)), + last: messages.some((element) => element.dataset.messageId === last), + bottomError: spacer ? spacer.bottom - view.bottom : undefined, + }) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + } + ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples + requestAnimationFrame(() => setTimeout(sample, 0)) + }, { destination, last }) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some( + (sample) => sample.destination, + ), + ) + const result = await page.evaluate(() => { + const samples = (window as Window & { + __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> + }).__coldTabSamples! + return samples.find((sample) => sample.destination)! + }) + expect(result.last).toBe(true) + expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + }) + test("renders seeded timeline in order while paging through history", async ({ page }) => { const errors = trackPageErrors(page) await mockOpenCodeServer(page, { @@ -427,6 +692,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin await expectSessionTitle(page, expectedTitle) } +async function switchTitlebarSession(page: Page, sessionID: string, title: string) { + const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + async function expectSessionReady(page: Page) { await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 9a03a9d5adb..c4ef9f6cc84 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -18,7 +18,10 @@ export interface MockServerConfig { project: unknown sessions: ({ id: string } & Record)[] pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } + messageDelay?: number + onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void events?: () => unknown[] + eventRetry?: number } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -44,7 +47,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) + if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) @@ -60,9 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { - const limit = Number(url.searchParams.get("limit") ?? 80) const before = url.searchParams.get("before") ?? undefined + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) + if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const limit = Number(url.searchParams.get("limit") ?? 80) const pageData = config.pageMessages(messagesMatch[1], limit, before) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) } @@ -83,10 +89,10 @@ function json(route: Route, body: unknown, headers?: Record) { }) } -function sse(route: Route, events?: unknown[]) { +function sse(route: Route, events?: unknown[], retry?: number) { return route.fulfill({ status: 200, contentType: "text/event-stream", - body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n", + body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } diff --git a/packages/app/package.json b/packages/app/package.json index 35054c5023e..b9cb6cd805e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,8 +17,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "bun run test:unit", + "test": "bun run test:unit && bun run test:virtualizer", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", @@ -64,6 +65,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", @@ -76,7 +78,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "tailwindcss": "catalog:", - "virtua": "catalog:" + "tailwindcss": "catalog:" } } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f884af2bf23..2811e6e3c6c 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => { }) describe("applyDirectoryEvent", () => { + test("initializes text delta accumulation from the current part text", () => { + const part = { ...textPart("part", "session", "message"), text: "existing" } + const [store, setStore] = createStore(baseState({ part: { message: [part] } })) + + applyDirectoryEvent({ + event: { + type: "message.part.delta", + properties: { messageID: "message", partID: "part", field: "text", delta: " appended" }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part_text_accum_delta.part).toBe("existing appended") + expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended") + }) + test("preserves a Home-specific retained session limit", () => { const [store, setStore] = createStore( baseState({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index f398aa995eb..9ca8f8bdad1 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: { if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) break - input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) + const field = props.field as keyof (typeof parts)[number] + const current = parts[result.index]?.[field] + input.setStore( + "part_text_accum_delta", + props.partID, + (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, + ) input.setStore( "part", props.messageID, diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 6d095697641..08390d667c6 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { resumeStreamAfterPageShow } from "./server-sdk" +import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk" +import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { @@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => { expect(starts).toBe(1) }) }) + +describe("coalesceServerEvents", () => { + const delta = (value: string, field = "text") => ({ + directory: "/repo", + payload: { + type: "message.part.delta", + properties: { messageID: "msg", partID: "part", field, delta: value }, + } as Event, + }) + + test("merges adjacent deltas for the same field", () => { + const result = coalesceServerEvents([delta("hello "), delta("world")]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } }) + }) + + test("preserves event boundaries and distinct fields", () => { + const status = { + directory: "/repo", + payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event, + } + const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")]) + + expect(result.map((event) => event.payload.type)).toEqual([ + "message.part.delta", + "message.part.delta", + "session.status", + "message.part.delta", + ]) + }) + + test("drops stale deltas", () => { + const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"])) + + expect(result).toEqual([]) + }) +}) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1f71b208241..a010e688415 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -15,6 +15,39 @@ const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true +type QueuedServerEvent = { directory: string; payload: Event } + +const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` + +export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set) { + const output: QueuedServerEvent[] = [] + const deltas = new Map() + events.forEach((event) => { + if (stale && event.payload.type === "message.part.delta") { + const props = event.payload.properties + if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return + } + if (event.payload.type !== "message.part.delta") { + deltas.clear() + output.push(event) + return + } + const props = event.payload.properties + const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}` + const index = deltas.get(id) + const existing = index === undefined ? undefined : output[index] + if (!existing || existing.payload.type !== "message.part.delta") { + deltas.set(id, output.length) + output.push({ + directory: event.directory, + payload: { ...event.payload, properties: { ...props } }, + }) + return + } + existing.payload.properties.delta += props.delta + }) + return output +} export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return @@ -45,7 +78,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS [key: string]: Event }>() - type Queued = { directory: string; payload: Event } + type Queued = QueuedServerEvent const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 const RECONNECT_DELAY_MS = 250 @@ -57,8 +90,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let timer: ReturnType | undefined let last = 0 - const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` - const key = (directory: string, payload: Event) => { if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` if (payload.type === "lsp.updated") return `lsp.updated:${directory}` @@ -83,14 +114,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS staleDeltas.clear() last = Date.now() + const output = coalesceServerEvents(events, skip) batch(() => { - for (const event of events) { - if (skip && event.payload.type === "message.part.delta") { - const props = event.payload.properties - if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue - } - emitter.emit(event.directory, event.payload) - } + output.forEach((event) => emitter.emit(event.directory, event.payload)) }) buffer.length = 0 diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d896ce97edc..be8d7c07860 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2366,7 +2366,7 @@ export default function Layout(props: ParentProps) { {props.children} - {import.meta.env.DEV && } + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && }
@@ -2519,7 +2519,7 @@ export default function Layout(props: ParentProps) { - {import.meta.env.DEV && } + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5f74650feed..72625a615fc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -13,7 +13,6 @@ import { on, onMount, untrack, - createResource, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -33,7 +32,6 @@ import { checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" -import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -54,7 +52,8 @@ import { shouldFocusTerminalOnKeyDown, shouldShowFileTree, } from "@/pages/session/helpers" -import { MessageTimeline } from "@/pages/session/message-timeline" +import { MessageTimeline } from "@/pages/session/timeline/message-timeline" +import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { useServer } from "@/context/server" @@ -67,11 +66,9 @@ import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" -import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" -const emptyUserMessages: UserMessage[] = [] type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick const emptyFollowups: FollowupItem[] = [] @@ -79,110 +76,6 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" -type SessionHistoryWindowInput = { - sessionID: () => string | undefined - loaded: () => number - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined -} - -function createSessionHistoryLoader(input: SessionHistoryWindowInput) { - const historyScrollThreshold = 200 - let shiftFrame: number | undefined - - const [state, setState] = createStore({ - shift: false, - }) - - const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, { - equals: same, - }) - - const cancelShiftReset = () => { - if (shiftFrame === undefined) return - cancelAnimationFrame(shiftFrame) - shiftFrame = undefined - } - - const scheduleShiftReset = () => { - cancelShiftReset() - shiftFrame = requestAnimationFrame(() => { - shiftFrame = undefined - setState("shift", false) - }) - } - - const fetchOlderMessages = async () => { - const id = input.sessionID() - if (!id) return - if (!input.historyMore() || input.historyLoading()) return - - // TODO(session-timeline): switch this to core cursor-based part pagination when that API lands. - const beforeVisible = input.visibleUserMessages().length - let loaded = input.loaded() - let growth = 0 - - cancelShiftReset() - setState("shift", true) - - while (true) { - await input.loadMore(id) - if (input.sessionID() !== id) return - - const nextLoaded = input.loaded() - const raw = nextLoaded - loaded - loaded = nextLoaded - growth = input.visibleUserMessages().length - beforeVisible - - if (growth > 0) break - if (raw <= 0) break - if (!input.historyMore()) break - } - - if (growth > 0) { - scheduleShiftReset() - return - } - - setState("shift", false) - } - - const loadAndReveal = () => fetchOlderMessages() - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollTop >= historyScrollThreshold) return - - void fetchOlderMessages() - } - - createEffect( - on( - input.sessionID, - () => { - cancelShiftReset() - setState({ shift: false }) - }, - { defer: true }, - ), - ) - - onCleanup(cancelShiftReset) - - return { - userMessages, - shift: () => state.shift, - loadAndReveal, - onScrollerScroll, - } -} - export default function Page() { const serverSync = useServerSync() const layout = useLayout() @@ -323,39 +216,15 @@ export default function Page() { const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) - const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) - const messagesReady = createMemo(() => { - const id = params.id - if (!id) return true - return sync().data.message[id] !== undefined - }) - const historyMore = createMemo(() => { - const id = params.id - if (!id) return false - return sync().session.history.more(id) - }) - const historyLoading = createMemo(() => { - const id = params.id - if (!id) return false - return sync().session.history.loading(id) - }) - const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], - emptyUserMessages, - { equals: same }, - ) - const visibleUserMessages = createMemo( - () => { - const revert = revertMessageID() - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }, - emptyUserMessages, - { - equals: same, - }, - ) - const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) + const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID }) + const historyLoading = timeline.history.loading + const historyMore = timeline.history.more + const lastUserMessage = timeline.lastUserMessage + const messages = timeline.messages + const messagesReady = timeline.ready + const sessionSync = timeline.resource + const userMessages = timeline.userMessages + const visibleUserMessages = timeline.visibleUserMessages createEffect(() => { const tab = activeFileTab() @@ -423,8 +292,6 @@ export default function Page() { }, sessionKey()) let reviewFrame: number | undefined - let refreshFrame: number | undefined - let refreshTimer: number | undefined let todoFrame: number | undefined let todoTimer: number | undefined let diffFrame: number | undefined @@ -614,6 +481,7 @@ export default function Page() { let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined let revealMessage = (_id: string) => {} + let scrollToEnd = () => {} let scrollMark = 0 let messageMark = 0 @@ -632,39 +500,6 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - const [sessionSync] = createResource( - () => [sdk().directory, params.id] as const, - ([directory, id]) => { - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - refreshFrame = undefined - refreshTimer = undefined - if (!id) return - - const cached = untrack(() => sync().data.message[id] !== undefined) - const stale = !cached - ? false - : (() => { - const info = getSessionPrefetch(serverSDK().scope, directory, id) - if (!info) return true - return Date.now() - info.at > SESSION_PREFETCH_TTL - })() - - refreshFrame = requestAnimationFrame(() => { - refreshFrame = undefined - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - if (params.id !== id) return - untrack(() => { - if (stale) void sync().session.sync(id, { force: true }) - }) - }, 0) - }) - - return sync().session.sync(id) - }, - ) - createEffect( on( () => { @@ -1202,8 +1037,18 @@ export default function Page() { const autoScroll = createAutoScroll({ working: () => true, - overflowAnchor: "dynamic", + overflowAnchor: "none", }) + createEffect( + on( + () => params.id, + (id, previous) => { + if (!id || !previous || id === previous) return + if (location.hash || store.messageId || ui.pendingMessage) return + autoScroll.resume() + }, + ), + ) let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined @@ -1239,7 +1084,8 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.resume() + scrollToEnd() clearMessageHash() const el = scroller @@ -1282,16 +1128,14 @@ export default function Page() { }, ) - const historyLoader = createSessionHistoryLoader({ - sessionID: () => params.id, - loaded: () => messages().length, - visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync().session.history.loadMore(sessionID), - userScrolled: autoScroll.userScrolled, - scroller: () => scroller, - }) + let captureHistoryAnchor = () => {} + let restoreHistoryAnchor = (_done: boolean) => {} + const loadOlder = () => + timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + const onHistoryScroll = () => { + if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return + void loadOlder() + } fill = () => { if (fillFrame !== undefined) return @@ -1307,7 +1151,7 @@ export default function Page() { if (el.scrollHeight > el.clientHeight + 1) return if (!historyMore()) return - void historyLoader.loadAndReveal() + void loadOlder() }) } @@ -1615,7 +1459,7 @@ export default function Page() { dockHeight = next - if (stick) autoScroll.forceScrollToBottom() + if (stick) scrollToEnd() if (el) scheduleScrollState(el) fill() @@ -1634,7 +1478,13 @@ export default function Page() { pendingMessage: () => ui.pendingMessage, setPendingMessage: (value) => setUi("pendingMessage", value), setActiveMessage, - autoScroll, + autoScroll: { + pause: autoScroll.pause, + forceScrollToBottom: () => { + autoScroll.resume() + scrollToEnd() + }, + }, scroller: () => scroller, anchor, revealMessage: (id) => revealMessage(id), @@ -1657,8 +1507,6 @@ export default function Page() { onCleanup(() => { if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) if (todoTimer !== undefined) window.clearTimeout(todoTimer) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) @@ -1791,37 +1639,45 @@ export default function Page() { - - - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } - centered={centered()} - setContentRef={(el) => { - content = el - autoScroll.contentRef(el) + + {(_id) => ( + + !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() + } + centered={centered()} + setContentRef={(el) => { + content = el + autoScroll.contentRef(el) - const root = scroller - if (root) scheduleScrollState(root) - }} - historyShift={historyLoader.shift()} - userMessages={historyLoader.userMessages()} - anchor={anchor} - setRevealMessage={(fn) => { - revealMessage = fn - }} - /> + const root = scroller + if (root) scheduleScrollState(root) + }} + userMessages={visibleUserMessages()} + setHistoryAnchor={(handlers) => { + captureHistoryAnchor = handlers.capture + restoreHistoryAnchor = handlers.restore + }} + anchor={anchor} + setRevealMessage={(fn) => { + revealMessage = fn + }} + setScrollToEnd={(fn) => { + scrollToEnd = fn + }} + /> + )} diff --git a/packages/app/src/pages/session/timeline/measure.test.ts b/packages/app/src/pages/session/timeline/measure.test.ts new file mode 100644 index 00000000000..b66f4e161f5 --- /dev/null +++ b/packages/app/src/pages/session/timeline/measure.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { scheduleConnectedMeasure } from "./measure" + +test("does not measure an element detached before the frame", async () => { + const element = document.createElement("div") + document.body.append(element) + let calls = 0 + + scheduleConnectedMeasure(element, () => { + calls += 1 + }) + element.remove() + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + + expect(calls).toBe(0) +}) + +test("measures a connected element on the next frame", async () => { + const element = document.createElement("div") + document.body.append(element) + let calls = 0 + + scheduleConnectedMeasure(element, () => { + calls += 1 + }) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + + expect(calls).toBe(1) + element.remove() +}) diff --git a/packages/app/src/pages/session/timeline/measure.ts b/packages/app/src/pages/session/timeline/measure.ts new file mode 100644 index 00000000000..c53df5f1455 --- /dev/null +++ b/packages/app/src/pages/session/timeline/measure.ts @@ -0,0 +1,5 @@ +export function scheduleConnectedMeasure(element: T, measure: (element: T) => void) { + return requestAnimationFrame(() => { + if (element.isConnected) measure(element) + }) +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx similarity index 79% rename from packages/app/src/pages/session/message-timeline.tsx rename to packages/app/src/pages/session/timeline/message-timeline.tsx index d025043c6d3..5c9de3d932f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -6,8 +6,8 @@ import { Index, on, onCleanup, + onMount, Show, - mapArray, type Accessor, type JSX, } from "solid-js" @@ -15,7 +15,7 @@ import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" -import { Virtualizer, type VirtualizerHandle } from "virtua/solid" +import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" @@ -49,7 +49,6 @@ import type { UserMessage, } from "@opencode-ai/sdk/v2" import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/ui/session-diff" @@ -69,7 +68,9 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { makeTimer } from "@solid-primitives/timer" -import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" +import { scheduleConnectedMeasure } from "./measure" +import { createTimelineProjection } from "./projection" +import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -77,43 +78,14 @@ const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } -type FramedTimelineRow = Exclude +type FramedTimelineRow = Exclude type TimelineRowByTag = Extract -function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((key, index) => key === b[index]) -} - -const timelineCacheLimit = 16 const timelineFallbackItemSize = 60 -const timelineCache = new Map() - -function readTimelineCache(id: string, keys: readonly string[]) { - const entry = timelineCache.get(id) - if (!entry) return - if (sameKeys(entry.keys, keys)) return entry.cache - timelineCache.delete(id) -} - -function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) { - if (!handle || keys.length === 0) return - timelineCache.delete(id) - timelineCache.set(id, { keys: keys.slice(), cache: handle.cache }) - while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!) -} - -function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { - if (!previous?.length) return rows - const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) - return rows.map((row) => { - const existing = byKey.get(TimelineRow.key(row)) - if (!existing) return row - return TimelineRow.equals(existing, row) ? existing : row - }) -} +const timelineCache = new Map< + string, + { measurements: VirtualItem[]; toolOpen: Record } +>() const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return @@ -278,10 +250,11 @@ export function MessageTimeline(props: { shouldAnchorBottom: () => boolean centered: boolean setContentRef: (el: HTMLDivElement) => void - historyShift: boolean userMessages: UserMessage[] anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void + setScrollToEnd?: (fn: () => void) => void + setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void }) { let touchGesture: number | undefined @@ -293,40 +266,21 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() + const ownerSessionKey = sessionKey() + const cached = timelineCache.get(ownerSessionKey) + const initialMeasurements = cached?.measurements + const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom() const platform = usePlatform() - let virtualizer: VirtualizerHandle | undefined + const [listRoot, setListRoot] = createSignal() const sessionID = createMemo(() => params.id) - const sessionMessages = createMemo(() => { - const id = sessionID() - if (!id) return emptyMessages - return sync().data.message[id] ?? emptyMessages - }) - const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) - const assistantMessagesByParent = createMemo(() => { - const result = new Map() - for (const message of sessionMessages()) { - if (message.role !== "assistant") continue - const messages = result.get(message.parentID) - if (messages) { - messages.push(message) - continue - } - result.set(message.parentID, [message]) - } - return result - }) - const pending = createMemo(() => - sessionMessages().findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ), - ) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync().data.session_status[id] ?? idle }) const working = createMemo(() => sessionStatus().type !== "idle") + const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent)) const [timeoutDone, setTimeoutDone] = createSignal(true) @@ -344,25 +298,6 @@ export function MessageTimeline(props: { makeTimer(() => setTimeoutDone(true), 260, setTimeout) }) - const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id - } - - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } - } - - return undefined - }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -385,6 +320,7 @@ export function MessageTimeline(props: { }) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts + const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) const childTaskDescription = createMemo(() => { const id = sessionID() if (!id) return @@ -401,147 +337,217 @@ export function MessageTimeline(props: { return language.t("command.session.new") }) const showHeader = createMemo(() => !!(titleValue() || parentID())) + const projection = createTimelineProjection({ + messages: sessionMessages, + userMessages: () => props.userMessages, + parts: getMsgParts, + status: sessionStatus, + showReasoningSummaries: settings.general.showReasoningSummaries, + }) + const activeMessageID = projection.activeMessageID + const assistantMessagesByParent = projection.assistantMessagesByParent + const lastAssistantGroupKey = projection.lastAssistantGroupKey + const messageByID = projection.messageByID + const messageLastRowIndex = projection.messageLastRowIndex + const messageRowIndex = projection.messageRowIndex + const timelineRowByKey = projection.rowByKey + const timelineRows = projection.rows - const messageRowMemos = createMemo( - mapArray( - () => props.userMessages, - (userMessage, indexAccessor) => { - return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { - const rows = Timeline.constructMessageRows( - userMessage, - getMsgParts, - assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, - indexAccessor(), - settings.general.showReasoningSummaries(), - sessionStatus().type, - activeMessageID() === userMessage.id, - ) + let prependAnchor: { key: string; offset: number } | undefined + let prependAnchorFrame: number | undefined + let prependLoading = false + const clearPrependAnchor = () => { + prependLoading = false + prependAnchor = undefined + if (prependAnchorFrame === undefined) return + cancelAnimationFrame(prependAnchorFrame) + prependAnchorFrame = undefined + } + const capturePrependAnchor = () => { + prependLoading = true + updatePrependAnchor() + } + const updatePrependAnchor = () => { + const root = listRoot() + if (!root) return + const view = root.getBoundingClientRect() + const anchor = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) + .sort((a, b) => a.rect.top - b.rect.top)[0] + if (!anchor) return + if (!anchor.element.dataset.timelineKey) return + prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top } + } + const restorePrependAnchor = (done: boolean) => { + if (done) prependLoading = false + applyPrependAnchor() + } + const applyPrependAnchor = () => { + const root = listRoot() + if (!root || !prependAnchor) return + if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) + let frames = 0 + let stable = 0 + const apply = () => { + prependAnchorFrame = undefined + const anchor = prependAnchor + if (!anchor) return + const element = root.querySelector(`[data-timeline-key="${CSS.escape(anchor.key)}"]`) + const delta = element + ? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset + : undefined + if (delta !== undefined && Math.abs(delta) > 0.5) { + root.scrollTop += delta + stable = 0 + } else { + stable += 1 + } + frames += 1 + if (stable >= 30 || frames >= 180) { + if (!prependLoading) prependAnchor = undefined + return + } + prependAnchorFrame = requestAnimationFrame(apply) + } + prependAnchorFrame = requestAnimationFrame(apply) + } - return reuseTimelineRows(previous, rows) + const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) + const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20) + let resizePinnedIndexes: number[] = [] + let resizePinFrame: number | undefined + let virtualContent: HTMLDivElement | undefined + const virtualizer = createVirtualizer({ + get count() { + return timelineRows().length + }, + getScrollElement: () => listRoot() ?? null, + initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + initialMeasurementsCache: initialMeasurements, + estimateSize: () => timelineFallbackItemSize, + scrollToFn: (offset, options, instance) => { + // Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height. + if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px` + elementScroll(offset, options, instance) + }, + get getItemKey() { + const rows = timelineRows() + return (index: number) => { + const row = rows[index] + // ResizeObserver can report a removed element after its row has left the projection. + if (!row) return `removed:${index}` + return TimelineRow.key(row) + } + }, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: 80, + get scrollMargin() { + return showHeader() ? 64 : 0 + }, + overscan: 50, + paddingEnd: 64, + rangeExtractor: (range) => { + const id = activeMessageID() + const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1 + const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() }) + return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b) + }, + }) + const resizeItem = virtualizer.resizeItem + virtualizer.resizeItem = (index, size) => { + const item = virtualizer.measurementsCache[index] + const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined + const root = listRoot() + if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) { + const view = root.getBoundingClientRect() + resizePinnedIndexes = [...root.querySelectorAll("[data-index]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom }) - }, - ), + .map((element) => Number(element.dataset.index)) + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + resizePinFrame = requestAnimationFrame(() => { + resizePinFrame = requestAnimationFrame(() => { + resizePinFrame = undefined + resizePinnedIndexes = [] + }) + }) + } + resizeItem(index, size) + } + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => + item.end <= instance.getLogicalScrollOffset() + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) - - const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { - const rows = messageRowMemos().flatMap((memo) => memo()) - if (rows.length === 0) return rows - return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) - }) - const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys }) - const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys())) - const messageRowIndex = createMemo(() => { - const result = new Map() - timelineRows().forEach((row, index) => { - if (!("userMessageID" in row)) return - if (result.has(row.userMessageID)) return - result.set(row.userMessageID, index) - }) - return result - }) - const lastAssistantGroupKey = createMemo(() => { - const result = new Map() - timelineRows().forEach((row) => { - if (row._tag !== "AssistantPart") return - result.set(row.userMessageID, row.group.key) - }) - return result - }) - const keepMounted = createMemo(() => { - const id = activeMessageID() - if (!id) return - const rows = timelineRows() - const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) - if (index < 0) return - return [index] - }) - const activeAssistantMessages = createMemo(() => { - const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id - if (!id) return emptyAssistantMessages - return assistantMessagesByParent().get(id) ?? emptyAssistantMessages - }) - const activeAssistantContentVersion = createMemo(() => - activeAssistantMessages() - .flatMap((message) => [ - `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`, - ...getMsgParts(message.id).map((part) => { - if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}` - if (part.type === "tool") { - const metadata = "metadata" in part.state ? part.state.metadata : undefined - const output = - "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0 - const metadataOutput = - metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string" - ? metadata.output.length - : 0 - return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}` - } - return `${part.id}:${part.type}` - }), - ]) - .join("|"), - ) - - createEffect( - on( - () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, - () => { - if (!virtualizer) return - if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return - const keys = timelineRowKeys() - if (keys.length === 0) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) - scheduleMeasuredBottomAnchor() - }, - { defer: true }, - ), - ) - + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) if (index === undefined) return - virtualizer?.scrollToIndex(index, { align: "center" }) + virtualizer.scrollToIndex(index, { align: "center" }) + }) + props.setScrollToEnd?.(() => virtualizer.scrollToEnd()) + props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) + }) + + let overscanFrame: number | undefined + onMount(() => { + overscanFrame = requestAnimationFrame(() => { + if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + overscanFrame = requestAnimationFrame(() => { + overscanFrame = undefined + if (renderOverscan() < 20) setRenderOverscan(20) + if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + }) }) }) - let cacheSessionKey = sessionKey() - let cacheRowKeys = timelineRowKeys() - let virtualizerSessionKey = cacheSessionKey - let virtualizerRowKeys = cacheRowKeys let bottomAnchorSessionKey = "" + let bottomAnchorFrame: number | undefined const maybeAnchorBottom = () => { const key = sessionKey() if (bottomAnchorSessionKey === key) return - if (!virtualizer) return - const keys = timelineRowKeys() - if (keys.length === 0) return + if (timelineRows().length === 0) return bottomAnchorSessionKey = key if (!props.shouldAnchorBottom()) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) + if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + clearPrependAnchor() + if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) + bottomAnchorFrame = requestAnimationFrame(() => { + bottomAnchorFrame = undefined + if (sessionKey() !== key) return + virtualizer.scrollToEnd() + }) } - createEffect( - on( - () => [sessionKey(), timelineRowKeys()] as const, - (next, prev) => { - if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer) - cacheSessionKey = next[0] - cacheRowKeys = next[1] - if (virtualizer) { - virtualizerSessionKey = cacheSessionKey - virtualizerRowKeys = cacheRowKeys - maybeAnchorBottom() - } - }, - { defer: true }, - ), - ) + let measuredSessionKey = sessionKey() + createEffect(() => { + const key = sessionKey() + timelineRows().length + if (measuredSessionKey !== key) { + measuredSessionKey = key + virtualizer.measure() + } + maybeAnchorBottom() + }) onCleanup(() => { - writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) + clearPrependAnchor() + timelineCache.delete(ownerSessionKey) + timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) + while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!) + if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) + if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) + if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame) props.setRevealMessage?.(() => {}) + props.setScrollToEnd?.(() => {}) + props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} }) }) const [title, setTitle] = createStore({ @@ -560,17 +566,8 @@ export function MessageTimeline(props: { const [bar, setBar] = createStore({ ms: pace(640), }) - const [toolOpen, setToolOpen] = createStore>({}) - let more: HTMLButtonElement | undefined let head: HTMLDivElement | undefined - let listRoot: HTMLDivElement | undefined - let listFrame: number | undefined - let contentFrame: number | undefined - let bottomAnchorFrame: number | undefined - let bottomAnchorFrames = 0 - let measuredBottomAnchored = true - const [scrollRoot, setScrollRoot] = createSignal() const updateTitleMetrics = () => { if (!head || head.clientWidth <= 0) return @@ -579,83 +576,14 @@ export function MessageTimeline(props: { createResizeObserver(() => head, updateTitleMetrics) - const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4 - - const measureTimeline = () => { - virtualizer?.measure() - anchorMeasuredBottom() - } - - function anchorMeasuredBottom() { - if (!listRoot) return false - if (!measuredBottomAnchored) return false - listRoot.scrollTop = listRoot.scrollHeight - return true - } - - function scheduleMeasuredBottomAnchor() { - // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for - // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual - // height, so keep the scroll container bottom-locked for a few frames while measurement settles. - bottomAnchorFrames = 90 - if (bottomAnchorFrame !== undefined) return - - const tick = () => { - bottomAnchorFrame = undefined - if (!anchorMeasuredBottom()) { - bottomAnchorFrames = 0 - return - } - - bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1 - if (bottomAnchorFrames <= 0) return - bottomAnchorFrame = requestAnimationFrame(tick) - } - - bottomAnchorFrame = requestAnimationFrame(tick) - } - - const bindContentRoot = (root: HTMLDivElement) => { - const child = root.firstElementChild - props.setContentRef(child instanceof HTMLDivElement ? child : root) - } - - const scheduleContentRoot = (root: HTMLDivElement) => { - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - contentFrame = requestAnimationFrame(() => { - contentFrame = undefined - if (listRoot !== root) return - bindContentRoot(root) - }) - } - - const connectListRoot = (root: HTMLDivElement) => { - if (listRoot !== root) return - if (!root.isConnected || !root.ownerDocument.defaultView) { - listFrame = requestAnimationFrame(() => { - listFrame = undefined - connectListRoot(root) - }) - return - } - - props.setScrollRef(root) - measuredBottomAnchored = isMeasuredBottom(root) - setScrollRoot(root) - scheduleContentRoot(root) - } - const bindListRoot = (root: HTMLDivElement) => { - if (root === listRoot) return - - if (listFrame !== undefined) cancelAnimationFrame(listFrame) - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - listRoot = root - setScrollRoot(undefined) - connectListRoot(root) + if (root === listRoot()) return + setListRoot(root) + props.setScrollRef(root) } const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { + if (!prependLoading) clearPrependAnchor() const root = event.currentTarget const delta = normalizeWheelDelta({ deltaY: event.deltaY, @@ -667,6 +595,7 @@ export function MessageTimeline(props: { } const handleListTouchStart = (event: TouchEvent) => { + if (!prependLoading) clearPrependAnchor() touchGesture = event.touches[0]?.clientY } @@ -692,12 +621,13 @@ export function MessageTimeline(props: { } const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { + if (!prependLoading) clearPrependAnchor() if (event.target !== event.currentTarget) return props.onMarkScrollGesture(event.currentTarget) } const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - measuredBottomAnchored = isMeasuredBottom(event.currentTarget) + if (prependLoading) updatePrependAnchor() props.onScheduleScrollState(event.currentTarget) props.onHistoryScroll() if (!props.hasScrollGesture()) return @@ -707,10 +637,6 @@ export function MessageTimeline(props: { } onCleanup(() => { - if (listFrame !== undefined) cancelAnimationFrame(listFrame) - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) - setScrollRoot(undefined) props.setScrollRef(undefined) }) @@ -1010,9 +936,7 @@ export function MessageTimeline(props: { } } - const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) - - const renderAssistantPartGroup = (row: Accessor) => { + const renderAssistantPartGroup = (row: Accessor, onSizeChange?: () => void) => { if (row().group.type === "context") { const parts = createMemo(() => { const group = row().group @@ -1028,7 +952,7 @@ export function MessageTimeline(props: { busy={ workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key } - onSizeChange={measureTimeline} + onSizeChange={onSizeChange} /> ) } @@ -1062,8 +986,9 @@ export function MessageTimeline(props: { defaultOpen={defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()} onToolOpenChange={(open) => setToolOpen(part().id, open)} - deferToolContent={false} + deferToolContent virtualizeDiff={false} + onContentRendered={onSizeChange} /> )} @@ -1077,10 +1002,6 @@ export function MessageTimeline(props: { const row = input.row() return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) } - const previousUserMessage = () => { - const row = input.row() - return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage - } const previousAssistantPart = () => { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart @@ -1095,7 +1016,6 @@ export function MessageTimeline(props: { "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:mx-auto": props.centered, - "pt-6": previousUserMessage(), "pt-3": previousAssistantPart(), }} > @@ -1106,8 +1026,10 @@ export function MessageTimeline(props: { ) } - const renderTimelineRow = (row: Accessor) => { + const renderTimelineRow = (row: Accessor, onSizeChange?: () => void) => { switch (row()._tag) { + case "TurnGap": + return @@ -1246,13 +1168,74 @@ export function MessageTimeline(props: { ) } - case "BottomSpacer": - return - - {(root) => ( - { - if (!handle) { - writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) - virtualizer = undefined - return - } - virtualizer = handle - virtualizerSessionKey = cacheSessionKey - virtualizerRowKeys = cacheRowKeys - maybeAnchorBottom() - scheduleContentRoot(root()) - }} - > - {(row) => } - - )} - +
{ + virtualContent = element + props.setContentRef(element) + }} + style={{ + height: `${virtualizer.getTotalSize()}px`, + position: "relative", + width: "100%", + }} + > + {(rowKey) => } + 0}> +
) diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts new file mode 100644 index 00000000000..054b81c75a1 --- /dev/null +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" +import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" + +const user = (id: string) => ({ id, role: "user" }) as UserMessage +const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage + +describe("timeline model", () => { + test("selects users and applies the revert boundary", () => { + const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")] + const users = selectUserMessages(messages) + + expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"]) + expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"]) + expect(selectVisibleUserMessages(users)).toBe(users) + }) + + test("loads pages until a visible user turn is added", async () => { + let loaded = 10 + let visible = 2 + let calls = 0 + const anchors: Array = [] + + await loadOlderTimeline({ + sessionID: () => "ses_test", + loaded: () => loaded, + visible: () => visible, + more: () => true, + loading: () => false, + loadMore: async () => { + calls += 1 + loaded += 3 + if (calls === 2) visible += 1 + }, + before: () => anchors.push("before"), + after: (done) => anchors.push("after", done), + }) + + expect(calls).toBe(2) + expect(anchors).toEqual(["before", "after", false, "after", true]) + }) + + test("stops when a page adds no raw messages", async () => { + let calls = 0 + await loadOlderTimeline({ + sessionID: () => "ses_test", + loaded: () => 10, + visible: () => 2, + more: () => true, + loading: () => false, + loadMore: async () => { + calls += 1 + }, + }) + + expect(calls).toBe(1) + }) + + test("does not restore an anchor after the session changes", async () => { + let sessionID = "ses_old" + let restore = 0 + + await loadOlderTimeline({ + sessionID: () => sessionID, + loaded: () => 10, + visible: () => 2, + more: () => true, + loading: () => false, + loadMore: async () => { + sessionID = "ses_new" + }, + after: () => { + restore += 1 + }, + }) + + expect(restore).toBe(0) + }) + + test("releases the anchor when loading history fails", async () => { + let restore = 0 + + await expect( + loadOlderTimeline({ + sessionID: () => "ses_test", + loaded: () => 10, + visible: () => 2, + more: () => true, + loading: () => false, + loadMore: async () => { + throw new Error("history failed") + }, + after: () => { + restore += 1 + }, + }), + ).rejects.toThrow("history failed") + + expect(restore).toBe(1) + }) +}) diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts new file mode 100644 index 00000000000..0514e497559 --- /dev/null +++ b/packages/app/src/pages/session/timeline/model.ts @@ -0,0 +1,152 @@ +import type { Message, UserMessage } from "@opencode-ai/sdk/v2" +import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" +import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" +import { useSync } from "@/context/sync" +import { same } from "@/utils/same" + +const emptyUserMessages: UserMessage[] = [] + +export function createTimelineModel(input: { + sessionID: Accessor + revertMessageID: Accessor +}) { + const sdk = useSDK() + const serverSDK = useServerSDK() + const sync = useSync() + let refreshFrame: number | undefined + let refreshTimer: number | undefined + + const [resource] = createResource( + () => [sdk().directory, input.sessionID()] as const, + ([directory, id]) => { + clearRefresh() + if (!id) return + + const cached = untrack(() => sync().data.message[id] !== undefined) + const stale = cached + ? (() => { + const info = getSessionPrefetch(serverSDK().scope, directory, id) + if (!info) return true + return Date.now() - info.at > SESSION_PREFETCH_TTL + })() + : false + + refreshFrame = requestAnimationFrame(() => { + refreshFrame = undefined + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + if (input.sessionID() !== id) return + untrack(() => { + if (stale) void sync().session.sync(id, { force: true }) + }) + }, 0) + }) + + return sync().session.sync(id) + }, + ) + const messages = createMemo(() => { + const id = input.sessionID() + return id ? (sync().data.message[id] ?? []) : [] + }) + const ready = createMemo(() => { + const id = input.sessionID() + return !id || sync().data.message[id] !== undefined + }) + const userMessages = createMemo( + () => selectUserMessages(messages()), + emptyUserMessages, + { equals: same }, + ) + const visibleUserMessages = createMemo( + () => { + return selectVisibleUserMessages(userMessages(), input.revertMessageID()) + }, + emptyUserMessages, + { equals: same }, + ) + const more = createMemo(() => { + const id = input.sessionID() + return id ? sync().session.history.more(id) : false + }) + const loading = createMemo(() => { + const id = input.sessionID() + return id ? sync().session.history.loading(id) : false + }) + const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => { + return loadOlderTimeline({ + sessionID: input.sessionID, + loaded: () => messages().length, + visible: () => visibleUserMessages().length, + more, + loading, + loadMore: (sessionID) => sync().session.history.loadMore(sessionID), + before: options?.before, + after: options?.after, + }) + } + + onCleanup(clearRefresh) + + return { + history: { loadOlder, loading, more }, + lastUserMessage: createMemo(() => visibleUserMessages().at(-1)), + messages, + ready, + resource, + userMessages, + visibleUserMessages, + } + + function clearRefresh() { + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + refreshFrame = undefined + refreshTimer = undefined + } +} + +export function selectUserMessages(messages: Message[]) { + return messages.filter((message): message is UserMessage => message.role === "user") +} + +export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) { + if (!revertMessageID) return messages + return messages.filter((message) => message.id < revertMessageID) +} + +export async function loadOlderTimeline(input: { + sessionID: Accessor + loaded: Accessor + visible: Accessor + more: Accessor + loading: Accessor + loadMore: (sessionID: string) => Promise + before?: () => void + after?: (done: boolean) => void +}) { + const id = input.sessionID() + if (!id || !input.more() || input.loading()) return + + // A history page may contain only assistant messages or user turns hidden by a revert boundary. + const beforeVisible = input.visible() + let loaded = input.loaded() + input.before?.() + while (true) { + await input.loadMore(id).catch((error) => { + if (input.sessionID() === id) input.after?.(true) + throw error + }) + if (input.sessionID() !== id) return + + const nextLoaded = input.loaded() + const growth = input.visible() - beforeVisible + const raw = nextLoaded - loaded + loaded = nextLoaded + const done = growth > 0 || raw <= 0 || !input.more() + input.after?.(done) + if (done) return + } +} diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts new file mode 100644 index 00000000000..dfa924923dd --- /dev/null +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -0,0 +1,113 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import { createMemo, mapArray, type Accessor } from "solid-js" +import { Timeline, TimelineRow } from "./rows" + +const emptyAssistantMessages: AssistantMessage[] = [] + +export function createTimelineProjection(input: { + messages: Accessor + userMessages: Accessor + parts: (messageID: string) => Part[] + status: Accessor + showReasoningSummaries: Accessor +}) { + const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) + const assistantMessagesByParent = createMemo(() => { + const result = new Map() + input.messages().forEach((message) => { + if (message.role !== "assistant") return + const messages = result.get(message.parentID) + if (messages) { + messages.push(message) + return + } + result.set(message.parentID, [message]) + }) + return result + }) + const activeMessageID = createMemo(() => { + const parentID = input.messages().findLast( + (message): message is AssistantMessage => message.role === "assistant" && typeof message.time.completed !== "number", + )?.parentID + if (parentID) { + const messages = input.messages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (message?.role === "user") return message.id + } + + if (input.status().type === "idle") return + return input.messages().findLast((message) => message.role === "user")?.id + }) + const messageRowMemos = createMemo( + mapArray(input.userMessages, (userMessage, indexAccessor) => + createMemo((previous: TimelineRow.TimelineRow[] | undefined) => + reuseTimelineRows( + previous, + Timeline.constructMessageRows( + userMessage, + input.parts, + assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, + indexAccessor(), + input.showReasoningSummaries(), + input.status().type, + activeMessageID() === userMessage.id, + ), + ), + ), + ), + ) + const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => + reuseTimelineRows( + previous, + messageRowMemos().flatMap((memo) => memo()), + ), + ) + const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const))) + const messageRowIndex = createMemo(() => { + const result = new Map() + rows().forEach((row, index) => { + if (!("userMessageID" in row) || result.has(row.userMessageID)) return + result.set(row.userMessageID, index) + }) + return result + }) + const messageLastRowIndex = createMemo(() => { + const result = new Map() + rows().forEach((row, index) => { + if ("userMessageID" in row) result.set(row.userMessageID, index) + }) + return result + }) + const lastAssistantGroupKey = createMemo(() => { + const result = new Map() + rows().forEach((row) => { + if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key) + }) + return result + }) + + return { + activeMessageID, + assistantMessagesByParent, + lastAssistantGroupKey, + messageByID, + messageRowIndex, + messageLastRowIndex, + rowByKey, + rows, + } +} + +export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { + if (!previous?.length) return rows + const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) + const next = rows.map((row) => { + const existing = byKey.get(TimelineRow.key(row)) + if (!existing) return row + return TimelineRow.equals(existing, row) ? existing : row + }) + if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous + return next +} diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/timeline/rows.ts similarity index 96% rename from packages/app/src/pages/session/message-timeline.data.ts rename to packages/app/src/pages/session/timeline/rows.ts index 0643a424ea1..bc5719bdcf8 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -6,14 +6,13 @@ import { Data, Equal } from "effect" export type SummaryDiff = SnapshotFileDiff & { file: string } export type TimelineRowMap = { + TurnGap: { userMessageID: string } CommentStrip: { userMessageID: string - previousUserMessage: boolean } UserMessage: { userMessageID: string anchor: boolean - previousUserMessage: boolean } TurnDivider: { userMessageID: string @@ -28,18 +27,18 @@ export type TimelineRowMap = { Retry: { userMessageID: string } DiffSummary: { userMessageID: string; diffs: SummaryDiff[] } Error: { userMessageID: string; text: string } - BottomSpacer: {} } export namespace TimelineRow { + export class TurnGap extends Data.TaggedClass("TurnGap")<{ + userMessageID: string + }> {} export class CommentStrip extends Data.TaggedClass("CommentStrip")<{ userMessageID: string - previousUserMessage: boolean }> {} export class UserMessage extends Data.TaggedClass("UserMessage")<{ userMessageID: string anchor: boolean - previousUserMessage: boolean }> {} export class TurnDivider extends Data.TaggedClass("TurnDivider")<{ userMessageID: string @@ -65,9 +64,9 @@ export namespace TimelineRow { export class Retry extends Data.TaggedClass("Retry")<{ userMessageID: string }> {} - export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {} export type TimelineRow = + | TurnGap | CommentStrip | UserMessage | TurnDivider @@ -76,10 +75,11 @@ export namespace TimelineRow { | DiffSummary | Error | Retry - | BottomSpacer export const key = (row: TimelineRow) => { switch (row._tag) { + case "TurnGap": + return `turn-gap:${row.userMessageID}` case "CommentStrip": return `comment-strip:${row.userMessageID}` case "UserMessage": @@ -96,8 +96,6 @@ export namespace TimelineRow { return `error:${row.userMessageID}` case "Retry": return `retry:${row.userMessageID}` - case "BottomSpacer": - return "bottom-spacer" } } @@ -149,11 +147,12 @@ export namespace Timeline { ), ] : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) + if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) + if (comments.length > 0) rows.push( new TimelineRow.CommentStrip({ userMessageID: userMessage.id, - previousUserMessage, }), ) @@ -161,7 +160,6 @@ export namespace Timeline { new TimelineRow.UserMessage({ userMessageID: userMessage.id, anchor: comments.length === 0, - previousUserMessage: comments.length === 0 && previousUserMessage, }), ) diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts new file mode 100644 index 00000000000..445fc799cbc --- /dev/null +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { createVirtualizer } from "@tanstack/solid-virtual" +import { createRoot, createSignal } from "solid-js" + +test("reactive count updates preserve measured row sizes", () => { + createRoot((dispose) => { + const [count, setCount] = createSignal(2) + const virtualizer = createVirtualizer({ + get count() { + return count() + }, + getScrollElement: () => null, + estimateSize: () => 60, + initialRect: { width: 800, height: 600 }, + }) + + expect(virtualizer.getTotalSize()).toBe(120) + virtualizer.resizeItem(0, 100) + expect(virtualizer.getTotalSize()).toBe(160) + + setCount(3) + + expect(virtualizer.itemSizeCache.get(0)).toBe(100) + expect(virtualizer.getTotalSize()).toBe(220) + dispose() + }) +}) + +test("logical scroll offset includes pending measurement adjustments", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: 2, + getScrollElement: () => null, + estimateSize: () => 60, + initialOffset: 100, + initialRect: { width: 800, height: 60 }, + }) + + virtualizer.getTotalSize() + virtualizer.resizeItem(0, 100) + + expect(virtualizer.scrollOffset).toBe(100) + expect(virtualizer.getLogicalScrollOffset()).toBe(140) + dispose() + }) +}) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index dc0742cf607..7bc76ed905b 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -140,10 +140,10 @@ describe("ShareNext", () => { it.live("create posts share, persists it, and returns the result", () => provideTmpdirInstance( () => { - const seen: HttpClientRequest.HttpClientRequest[] = [] + const createRequests: HttpClientRequest.HttpClientRequest[] = [] const client = HttpClient.make((req) => { - seen.push(req) if (req.url.endsWith("/api/share")) { + createRequests.push(req) return Effect.succeed( json(req, { id: "shr_abc", @@ -168,9 +168,9 @@ describe("ShareNext", () => { expect(row?.url).toBe("https://legacy-share.example.com/share/abc") expect(row?.secret).toBe("sec_123") - expect(seen).toHaveLength(1) - expect(seen[0].method).toBe("POST") - expect(seen[0].url).toBe("https://legacy-share.example.com/api/share") + expect(createRequests).toHaveLength(1) + expect(createRequests[0].method).toBe("POST") + expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share") }).pipe(Effect.provide(integrationLayer(client))) }, { config: { enterprise: { url: "https://legacy-share.example.com" } } }, diff --git a/packages/ui/package.json b/packages/ui/package.json index b5b109ed067..7e665e294eb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@shikijs/stream": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -76,7 +77,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "strip-ansi": "7.1.2", - "virtua": "catalog:" + "strip-ansi": "7.1.2" } } diff --git a/packages/ui/src/components/file.css b/packages/ui/src/components/file.css index a9150e14503..a17732f6145 100644 --- a/packages/ui/src/components/file.css +++ b/packages/ui/src/components/file.css @@ -2,6 +2,10 @@ content-visibility: auto; } +[data-timeline-row] [data-component="file"] { + content-visibility: visible; +} + [data-component="file"][data-mode="text"] { overflow: hidden; } diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index bfcc05b40ac..8c8096375a3 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - fileGap: 0, + spacing: 0, } satisfies Partial type SharedProps = { diff --git a/packages/ui/src/components/markdown-code-state.test.ts b/packages/ui/src/components/markdown-code-state.test.ts new file mode 100644 index 00000000000..1be76d6f16c --- /dev/null +++ b/packages/ui/src/components/markdown-code-state.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { shouldResetCodeTokens } from "./markdown-code-state" + +const previous = { + language: "ts", + generation: 1, + stableCount: 3, + unstable: [], + raw: "```ts\nconst x = 1\n```", +} + +test("resets tokens for a non-prefix replacement with the same generation and token count", () => { + expect( + shouldResetCodeTokens(previous, { + language: "ts", + generation: 1, + stableCount: 3, + raw: "```ts\nlet y = 2\n```", + }), + ).toBe(true) +}) + +test("retains tokens for an append-only streaming update", () => { + expect( + shouldResetCodeTokens(previous, { + language: "ts", + generation: 1, + stableCount: 4, + raw: `${previous.raw}\nmore`, + }), + ).toBe(false) +}) diff --git a/packages/ui/src/components/markdown-code-state.ts b/packages/ui/src/components/markdown-code-state.ts new file mode 100644 index 00000000000..502f3ecd3c2 --- /dev/null +++ b/packages/ui/src/components/markdown-code-state.ts @@ -0,0 +1,22 @@ +import type { MarkdownToken } from "./markdown-worker-protocol" + +export type RenderedCodeState = { + language: string + generation: number + stableCount: number + unstable: MarkdownToken[] + raw: string +} + +export function shouldResetCodeTokens( + previous: RenderedCodeState | undefined, + next: { language: string; generation: number; stableCount: number; raw: string }, +) { + return ( + !previous || + previous.language !== next.language || + previous.generation !== next.generation || + next.stableCount < previous.stableCount || + !next.raw.startsWith(previous.raw) + ) +} diff --git a/packages/ui/src/components/markdown-shiki.worker.ts b/packages/ui/src/components/markdown-shiki.worker.ts new file mode 100644 index 00000000000..d7acabee7b6 --- /dev/null +++ b/packages/ui/src/components/markdown-shiki.worker.ts @@ -0,0 +1,104 @@ +/// + +import { ShikiStreamTokenizer } from "@shikijs/stream" +import { + bundledLanguages, + createHighlighter, + getTokenStyleObject, + stringifyTokenStyle, + type BundledLanguage, + type ThemedToken, +} from "shiki" +import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol" +import { createLatestWorkerQueue } from "./markdown-worker-queue" + +type Stream = { + language: string + source: string + tokenizer: ShikiStreamTokenizer +} + +const streams = new Map() +let highlighter: ReturnType | undefined +const queue = createLatestWorkerQueue>({ + run: highlight, + supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }), + dispose: (key) => void streams.delete(key), +}) + +self.onmessage = (event: MessageEvent) => { + if (event.data.type === "init") { + highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] }) + return + } + if (event.data.type === "dispose") { + queue.dispose(event.data.key) + return + } + + queue.highlight(event.data) +} + +async function highlight(request: Extract) { + try { + const instance = await highlighter + if (!instance) throw new Error("Shiki worker is not initialized") + const language = request.language in bundledLanguages ? request.language : "text" + if (!instance.getLoadedLanguages().includes(language)) + await instance.loadLanguage(bundledLanguages[language as BundledLanguage]) + + if (request.complete) { + const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" }) + streams.delete(request.key) + post({ + type: "highlight", + id: request.id, + key: request.key, + reset: true, + stable: result.tokens + .flatMap((line, index) => + index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }], + ) + .map(token), + unstable: [], + }) + return + } + + const previous = streams.get(request.key) + const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source) + const stream = reset + ? { + language, + source: "", + tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }), + } + : previous + const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length)) + stream.source = request.text + streams.set(request.key, stream) + post({ + type: "highlight", + id: request.id, + key: request.key, + reset, + stable: result.stable.filter((token) => token.content.length > 0).map(token), + unstable: result.unstable.filter((token) => token.content.length > 0).map(token), + }) + } catch (error) { + post({ + type: "error", + id: request.id, + key: request.key, + message: error instanceof Error ? error.message : String(error), + }) + } +} + +function post(response: MarkdownWorkerResponse) { + self.postMessage(response) +} + +function token(value: ThemedToken): MarkdownToken { + return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))] +} diff --git a/packages/ui/src/components/markdown-stream.test.ts b/packages/ui/src/components/markdown-stream.test.ts index 1ee63fc62e4..16727861046 100644 --- a/packages/ui/src/components/markdown-stream.test.ts +++ b/packages/ui/src/components/markdown-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { stream } from "./markdown-stream" +import { canReusePendingBlock, project, stream } from "./markdown-stream" describe("markdown stream", () => { test("heals incomplete emphasis while streaming", () => { @@ -15,8 +15,58 @@ describe("markdown stream", () => { test("splits an unfinished trailing code fence from stable content", () => { expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([ - { raw: "before\n\n", src: "before\n\n", mode: "live" }, - { raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" }, + { raw: "before\n\n", src: "before\n\n", mode: "full" }, + { raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" }, + ]) + }) + + test("fully parses a code fence once it closes", () => { + const text = "before\n\n```ts\nconst x = 1\n```" + expect(stream(text, true)).toEqual([ + { raw: "before\n\n", src: "before\n\n", mode: "full" }, + { raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true }, + ]) + }) + + test("keeps a completed code fence in worker-rendered code mode when prose follows", () => { + expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([ + { raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true }, + { raw: "after", src: "after", mode: "live" }, + ]) + }) + + test("freezes completed top-level blocks and only keeps the tail live", () => { + expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([ + { raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" }, + { raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" }, + { raw: "- live item", src: "- live item", mode: "live" }, + ]) + }) + + test("keeps a growing table together until a later block freezes it", () => { + expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([ + { raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" }, + ]) + }) + + test("reprojects non-prefix replacements from current content", () => { + expect(stream("# Replacement\n\nNew body", true)).toEqual([ + { raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" }, + { raw: "New body", src: "New body", mode: "live" }, + ]) + }) + + test("reprojects truncation without retaining removed blocks", () => { + expect(stream("Only the restored prefix", true)).toEqual([ + { raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" }, + ]) + }) + + test("shifts later blocks when an earlier block is inserted", () => { + expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([ + { raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" }, + { raw: "First body\n\n", src: "First body\n\n", mode: "full" }, + { raw: "Second body", src: "Second body", mode: "live" }, ]) }) @@ -29,4 +79,112 @@ describe("markdown stream", () => { }, ]) }) + + test("keeps compact and indented reference definitions with their uses", () => { + expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([ + { + raw: "[docs]\n\n [docs]:/guide", + src: "[docs]\n\n [docs]:/guide", + mode: "live", + }, + ]) + }) + + test("keeps multiline reference definitions with their uses", () => { + expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([ + { + raw: "[docs][id]\n\n[id]:\n /guide", + src: "[docs][id]\n\n[id]:\n /guide", + mode: "live", + }, + ]) + }) + + test("uses only the language portion of fence metadata", () => { + expect(stream("```ts title=example\nconst x = 1", true)).toEqual([ + { + raw: "```ts title=example\nconst x = 1", + src: "const x = 1", + mode: "code", + language: "ts", + }, + ]) + }) + + test("preserves trailing newlines in open code fences", () => { + expect(stream("```ts\nconst x = 1\n", true)).toEqual([ + { + raw: "```ts\nconst x = 1\n", + src: "const x = 1\n", + mode: "code", + language: "ts", + }, + ]) + }) + + test("only reuses pending blocks with compatible identity and content", () => { + expect(canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "", })).toBe(false) + expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" })).toBe(true) + expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false) + }) + + test("appends plain code deltas without reprojecting frozen blocks", () => { + const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true) + const next = project(previous, `${previous.text}const two = 2\n`, true) + + expect(next.blocks[0]).toBe(previous.blocks[0]) + expect(next.blocks.at(-1)).toEqual({ + raw: "```ts\nconst one = 1\nconst two = 2\n", + src: "const one = 1\nconst two = 2\n", + mode: "code", + language: "ts", + }) + }) + + 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) + + expect(next.blocks.at(-1)).toEqual({ + raw: "```ts\nconst x = 1", + src: "const x = 1", + mode: "code", + language: "ts", + }) + }) + + test("closes code fences split across provider deltas", () => { + const open = project(undefined, "```ts\nconst x = 1\n", true) + const one = project(open, `${open.text}\``, true) + const two = project(one, `${one.text}\``, true) + const closed = project(two, `${two.text}\``, true) + const prose = project(closed, `${closed.text}\nafter`, true) + + expect(closed.blocks.at(-1)).toEqual({ + raw: "```ts\nconst x = 1\n```", + src: "const x = 1", + mode: "code", + language: "ts", + complete: true, + }) + expect(prose.blocks).toEqual([ + { raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true }, + { raw: "after", src: "after", mode: "live" }, + ]) + }) + + test("closes tilde fences split across provider deltas", () => { + const open = project(undefined, "~~~ts\nconst x = 1\n", true) + const one = project(open, `${open.text}~`, true) + const two = project(one, `${one.text}~`, true) + const closed = project(two, `${two.text}~`, true) + + expect(closed.blocks.at(-1)).toEqual({ + raw: "~~~ts\nconst x = 1\n~~~", + src: "const x = 1", + mode: "code", + language: "ts", + complete: true, + }) + }) }) diff --git a/packages/ui/src/components/markdown-stream.ts b/packages/ui/src/components/markdown-stream.ts index ea35b0c140d..3b1c2526603 100644 --- a/packages/ui/src/components/markdown-stream.ts +++ b/packages/ui/src/components/markdown-stream.ts @@ -4,11 +4,28 @@ import remend from "remend" export type Block = { raw: string src: string - mode: "full" | "live" + mode: "full" | "live" | "code" + language?: string + complete?: boolean +} + +export type Projection = { + text: string + blocks: Block[] } function refs(text: string) { - return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text) + if (!text.includes("]:")) return false + return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text) +} + +function language(value: string | undefined) { + return value?.trim().split(/\s+/, 1)[0] || undefined +} + +function openCode(raw: string) { + const newline = raw.indexOf("\n") + return newline < 0 ? "" : raw.slice(newline + 1) } function open(raw: string) { @@ -22,28 +39,72 @@ function open(raw: string) { return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last) } +function closesFence(raw: string, suffix: string) { + const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1] + if (!mark) return suffix.includes("```") || suffix.includes("~~~") + return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark) +} + function heal(text: string) { return remend(text, { linkMode: "text-only" }) } -export function stream(text: string, live: boolean) { +export function stream(text: string, live: boolean): Block[] { if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[] - const src = heal(text) - if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[] + 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") - if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] + if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const last = tokens[tail] - if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[] - const code = last as Tokens.Code - if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[] - const head = tokens - .slice(0, tail) + if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] + + const result: Block[] = [] + for (let index = 0; index < tail; index++) { + const token = tokens[index] + if (!token || token.type === "space") continue + let raw = token.raw + while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw + if (token.type === "code") { + const code = token as Tokens.Code + result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true }) + continue + } + result.push({ raw, src: raw, mode: "full" }) + } + + const raw = tokens + .slice(tail) .map((token) => token.raw) .join("") - if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[] - return [ - { raw: head, src: heal(head), mode: "live" }, - { raw: code.raw, src: code.raw, mode: "live" }, - ] satisfies Block[] + if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }] + + const code = last as Tokens.Code + if (!open(code.raw)) + return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }] + return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }] +} + +export function canReusePendingBlock(current: Pick | 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) } + const tail = previous.blocks.at(-1) + const suffix = text.slice(previous.text.length) + if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix)) + return { text, blocks: stream(text, live) } + return { + text, + blocks: [ + ...previous.blocks.slice(0, -1), + { + ...tail, + raw: tail.raw + suffix, + src: tail.src + suffix, + }, + ], + } } diff --git a/packages/ui/src/components/markdown-worker-protocol.test.ts b/packages/ui/src/components/markdown-worker-protocol.test.ts new file mode 100644 index 00000000000..0c84ac557a6 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test" +import { applyMarkdownWorkerResponse, markdownBlockKey, shouldReleaseMarkdownWorkerState } from "./markdown-worker-protocol" + +const token = (content: string): [string, string] => [content, ""] +const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({ + type: "highlight" as const, + id, + key: "code", + reset, + stable, + unstable, +}) + +test("accumulates stable worker tokens and replaces the unstable tail", () => { + const first = applyMarkdownWorkerResponse(undefined, { + type: "highlight", + id: 1, + key: "code", + reset: true, + stable: [token("one\n")], + unstable: [token("tw")], + }) + const second = applyMarkdownWorkerResponse(first, { + type: "highlight", + id: 2, + key: "code", + 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"]) +}) + +test("increments generation only when the worker resets token identity", () => { + const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], [])) + const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], [])) + const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], [])) + expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2]) +}) + +test("ignores stale worker responses and resets replacement streams", () => { + const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] } + expect( + applyMarkdownWorkerResponse(current, { + type: "highlight", + id: 1, + key: "code", + reset: false, + stable: [token("stale")], + unstable: [], + }), + ).toBe(current) + + expect( + applyMarkdownWorkerResponse(current, { + type: "highlight", + id: 3, + key: "code", + reset: true, + stable: [token("replacement")], + unstable: [], + }).stable.map((item) => item[0]), + ).toEqual(["replacement"]) +}) + +test("releases only the latest completed worker state", () => { + expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true) + expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false) + expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false) +}) + +test("prefixes pending and dispatched block keys with the component owner", () => { + expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code") + expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2") +}) diff --git a/packages/ui/src/components/markdown-worker-protocol.ts b/packages/ui/src/components/markdown-worker-protocol.ts new file mode 100644 index 00000000000..e0b59e38df2 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.ts @@ -0,0 +1,48 @@ +import type { ThemeRegistrationResolved } from "shiki" + +export type MarkdownToken = [content: string, style: string] + +export type MarkdownWorkerRequest = + | { type: "init"; theme: ThemeRegistrationResolved } + | { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean } + | { type: "dispose"; key: string } + +export type MarkdownWorkerResponse = + | { + type: "highlight" + id: number + key: string + reset: boolean + stable: MarkdownToken[] + unstable: MarkdownToken[] + } + | { type: "error"; id: number; key: string; message: string } + | { type: "superseded"; id: number; key: string } + +export type MarkdownWorkerState = { + id: number + generation: number + stable: MarkdownToken[] + unstable: MarkdownToken[] +} + +export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) { + return complete && latestID === responseID +} + +export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) { + return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}` +} + +export function applyMarkdownWorkerResponse( + state: MarkdownWorkerState | undefined, + response: Extract, +) { + if (state && response.id <= state.id) return state + return { + id: response.id, + generation: (state?.generation ?? 0) + (response.reset ? 1 : 0), + stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable], + unstable: response.unstable, + } +} diff --git a/packages/ui/src/components/markdown-worker-queue.test.ts b/packages/ui/src/components/markdown-worker-queue.test.ts new file mode 100644 index 00000000000..4283bc958f7 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-queue.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { createLatestWorkerQueue } from "./markdown-worker-queue" + +test("keeps only the latest queued request for each key", async () => { + const processed: number[] = [] + const superseded: number[] = [] + let release = () => {} + const blocked = new Promise((resolve) => { + release = resolve + }) + const queue = createLatestWorkerQueue<{ id: number; key: string }>({ + run: async (request) => { + processed.push(request.id) + if (request.id === 1) await blocked + }, + supersede: (request) => superseded.push(request.id), + dispose: () => {}, + }) + + queue.highlight({ id: 1, key: "code" }) + await Promise.resolve() + queue.highlight({ id: 2, key: "code" }) + queue.highlight({ id: 3, key: "code" }) + queue.highlight({ id: 4, key: "code" }) + + expect(queue.pending()).toBe(1) + expect(superseded).toEqual([2, 3]) + release() + await queue.idle() + expect(processed).toEqual([1, 4]) +}) + +test("serializes disposal before a later request for the same key", async () => { + const events: string[] = [] + const queue = createLatestWorkerQueue<{ id: number; key: string }>({ + run: async (request) => { + events.push(`highlight:${request.id}`) + }, + supersede: (request) => events.push(`supersede:${request.id}`), + dispose: (key) => events.push(`dispose:${key}`), + }) + + queue.highlight({ id: 1, key: "code" }) + queue.dispose("code") + queue.highlight({ id: 2, key: "code" }) + await queue.idle() + + expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"]) +}) diff --git a/packages/ui/src/components/markdown-worker-queue.ts b/packages/ui/src/components/markdown-worker-queue.ts new file mode 100644 index 00000000000..f7711576348 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-queue.ts @@ -0,0 +1,64 @@ +export function createLatestWorkerQueue(input: { + run: (request: T) => Promise + supersede: (request: T) => void + dispose: (key: string) => void +}) { + type Slot = { type: "highlight"; key: string; request?: T } + const jobs: Array = [] + const slots = new Map() + let running: Promise | undefined + let cursor = 0 + + const schedule = () => { + if (running) return + running = Promise.resolve() + .then(async () => { + while (cursor < jobs.length) { + const job = jobs[cursor++]! + if (job.type === "dispose") { + input.dispose(job.key) + continue + } + if (slots.get(job.key) === job) slots.delete(job.key) + const request = job.request + job.request = undefined + if (request) await input.run(request) + } + }) + .finally(() => { + jobs.splice(0, cursor) + cursor = 0 + running = undefined + if (jobs.length > 0) schedule() + }) + } + + return { + highlight(request: T) { + const slot = slots.get(request.key) + if (slot) { + if (slot.request) input.supersede(slot.request) + slot.request = request + return + } + const next: Slot = { type: "highlight", key: request.key, request } + slots.set(request.key, next) + jobs.push(next) + schedule() + }, + dispose(key: string) { + const slot = slots.get(key) + if (slot?.request) input.supersede(slot.request) + if (slot) { + slot.request = undefined + slots.delete(key) + } + jobs.push({ type: "dispose", key }) + schedule() + }, + pending: () => slots.size, + async idle() { + while (running) await running + }, + } +} diff --git a/packages/ui/src/components/markdown-worker-transport.test.ts b/packages/ui/src/components/markdown-worker-transport.test.ts new file mode 100644 index 00000000000..24cf842a4a3 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-transport.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test" +import { createWorkerTransport } from "./markdown-worker-transport" + +test("posts one request and retains only the latest queued snapshot per key", () => { + const posted: number[] = [] + const superseded: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: (request) => posted.push(request.id), + supersede: (request) => superseded.push(request.id), + }) + + transport.send({ id: 1, key: "code" }) + transport.send({ id: 2, key: "code" }) + transport.send({ id: 3, key: "code" }) + + expect(posted).toEqual([1]) + expect(superseded).toEqual([2]) + expect(transport.queued()).toBe(1) + transport.complete("code", 1) + expect(posted).toEqual([1, 3]) + expect(transport.queued()).toBe(0) +}) + +test("ignores a disposed request response after the key is reused", () => { + const posted: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: (request) => posted.push(request.id), + supersede: () => {}, + }) + + transport.send({ id: 1, key: "code" }) + transport.dispose("code") + transport.send({ id: 2, key: "code" }) + transport.send({ id: 3, key: "code" }) + transport.complete("code", 1) + + expect(posted).toEqual([1, 2]) + expect(transport.queued()).toBe(1) + transport.complete("code", 2) + expect(posted).toEqual([1, 2, 3]) +}) + +test("drops queued snapshots when a key is disposed", () => { + const superseded: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: () => {}, + supersede: (request) => superseded.push(request.id), + }) + + transport.send({ id: 1, key: "code" }) + transport.send({ id: 2, key: "code" }) + transport.dispose("code") + + expect(superseded).toEqual([2]) + expect(transport.queued()).toBe(0) +}) diff --git a/packages/ui/src/components/markdown-worker-transport.ts b/packages/ui/src/components/markdown-worker-transport.ts new file mode 100644 index 00000000000..acdac7b1c93 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-transport.ts @@ -0,0 +1,41 @@ +export function createWorkerTransport(input: { + post: (request: T) => void + supersede: (request: T) => void +}) { + const active = new Map() + const queued = new Map() + + return { + send(request: T) { + if (!active.has(request.key)) { + active.set(request.key, request) + input.post(request) + return + } + const previous = queued.get(request.key) + if (previous) input.supersede(previous) + queued.set(request.key, request) + }, + complete(key: string, id: number) { + if (active.get(key)?.id !== id) return + active.delete(key) + const next = queued.get(key) + if (!next) return + queued.delete(key) + active.set(key, next) + input.post(next) + }, + dispose(key: string) { + active.delete(key) + const request = queued.get(key) + if (request) input.supersede(request) + queued.delete(key) + }, + reset() { + queued.forEach(input.supersede) + queued.clear() + active.clear() + }, + queued: () => queued.size, + } +} diff --git a/packages/ui/src/components/markdown-worker.ts b/packages/ui/src/components/markdown-worker.ts new file mode 100644 index 00000000000..a48e98bbd0a --- /dev/null +++ b/packages/ui/src/components/markdown-worker.ts @@ -0,0 +1,122 @@ +import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url" +import { OpenCodeTheme } from "../context/marked" +import { + applyMarkdownWorkerResponse, + shouldReleaseMarkdownWorkerState, + type MarkdownWorkerRequest, + type MarkdownWorkerResponse, + type MarkdownWorkerState, +} from "./markdown-worker-protocol" +import { createWorkerTransport } from "./markdown-worker-transport" + +type Pending = { + key: string + complete: boolean + resolve: (state: MarkdownWorkerState) => void + reject: (error: Error) => void +} + +let worker: Worker | undefined +let disabled: Error | undefined +let nextID = 0 +const pending = new Map() +const states = new Map() +const keys = new Set() +const latest = new Map() +const transport = createWorkerTransport>({ + post: (request) => worker!.postMessage(request), + supersede: (request) => { + const result = pending.get(request.id) + if (!result) return + pending.delete(request.id) + result.reject(new MarkdownWorkerSupersededError()) + }, +}) + +export function highlightStreamingCode(key: string, text: string, language: string, complete = false) { + const instance = getWorker() + const id = ++nextID + latest.set(key, id) + keys.delete(key) + keys.add(key) + if (keys.size > 200) disposeStreamingCode(keys.values().next().value!) + return new Promise((resolve, reject) => { + pending.set(id, { key, complete, resolve, reject }) + transport.send({ type: "highlight", id, key, text, language, complete }) + }) +} + +export function disposeStreamingCode(key: string) { + keys.delete(key) + latest.delete(key) + states.delete(key) + transport.dispose(key) + pending.forEach((request, id) => { + if (request.key !== key) return + pending.delete(id) + request.reject(new MarkdownWorkerDisposedError()) + }) + worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest) +} + +export class MarkdownWorkerDisposedError extends Error {} +export class MarkdownWorkerSupersededError extends Error {} +export class MarkdownWorkerUnavailableError extends Error {} + +function getWorker() { + if (worker) return worker + if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message) + try { + worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" }) + } catch (error) { + disabled = error instanceof Error ? error : new Error(String(error)) + throw new MarkdownWorkerUnavailableError(disabled.message) + } + worker.onmessage = (event: MessageEvent) => { + const result = pending.get(event.data.id) + if (!result) { + transport.complete(event.data.key, event.data.id) + return + } + pending.delete(event.data.id) + if (!keys.has(event.data.key)) { + result.reject(new MarkdownWorkerDisposedError()) + transport.complete(event.data.key, event.data.id) + return + } + if (event.data.type === "superseded") { + result.reject(new MarkdownWorkerSupersededError()) + transport.complete(event.data.key, event.data.id) + return + } + if (event.data.type === "error") { + result.reject(new Error(event.data.message)) + transport.complete(event.data.key, event.data.id) + return + } + const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data) + if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) { + states.delete(event.data.key) + keys.delete(event.data.key) + latest.delete(event.data.key) + } else states.set(event.data.key, state) + result.resolve(state) + transport.complete(event.data.key, event.data.id) + } + const fail = (message: string) => { + const error = new Error(message) + disabled = error + transport.reset() + pending.forEach((request) => request.reject(error)) + pending.clear() + states.clear() + keys.clear() + latest.clear() + worker?.terminate() + worker = undefined + } + worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed") + worker.onmessageerror = () => fail("Markdown worker response failed") + worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest) + return worker +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 26c9efd475e..bfe4bb6f1a6 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -15,6 +15,12 @@ > *:last-child { margin-bottom: 0; } + > [data-markdown-block]:first-child > *:first-child { + margin-top: 0; + } + > [data-markdown-block]:last-child > *:last-child { + margin-bottom: 0; + } /* Headings: Same size, distinguished by color and spacing */ h1, @@ -121,6 +127,8 @@ } .shiki { + background: var(--color-background-stronger); + color: var(--text-base); font-size: 13px; padding: 12px; border-radius: 6px; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index e12b661dba6..018a6a7ddbd 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -3,17 +3,48 @@ import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" -import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" -import { stream } from "./markdown-stream" +import { bundledLanguages } from "shiki" +import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream" +import { + disposeStreamingCode, + highlightStreamingCode, + MarkdownWorkerDisposedError, + MarkdownWorkerSupersededError, + MarkdownWorkerUnavailableError, +} from "./markdown-worker" +import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" +import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" type Entry = { + raw: string hash: string html: string } +type RenderedBlock = + | (Entry & { key: string; mode: Exclude }) + | { + key: string + mode: "code" + raw: string + hash: string + language: string + complete: boolean + generation: number + stable: MarkdownToken[] + unstable: MarkdownToken[] + } + +type RenderResult = { + text: string + blocks: RenderedBlock[] +} + const max = 200 const cache = new Map() +const renderedCodeTokens = new WeakMap() if (typeof window !== "undefined" && DOMPurify.isSupported) { DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { @@ -60,6 +91,22 @@ function fallback(markdown: string) { return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") } +async function code(text: string, language: string | undefined, key: string, complete = false) { + const name = language && language in bundledLanguages ? language : "text" + try { + const result = await highlightStreamingCode(key, text, name, complete) + return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable } + } catch (error) { + if ( + !(error instanceof MarkdownWorkerDisposedError) && + !(error instanceof MarkdownWorkerSupersededError) && + !(error instanceof MarkdownWorkerUnavailableError) + ) + console.error("Markdown highlighting worker failed", error) + return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] } + } +} + type CopyLabels = { copy: string copied: string @@ -238,6 +285,33 @@ function touch(key: string, value: Entry) { cache.delete(first) } +function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult { + if (!text) return { text, blocks: [] } + const base = key ?? checksum(text) + if (base) { + const blocks = projection.blocks.flatMap((block, index) => { + if (block.mode === "code") return [] + const cacheKey = `${base}:${index}:${block.mode}` + const cached = cache.get(cacheKey) + if (cached?.raw !== block.raw) return [] + return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] + }) + if (blocks.length === projection.blocks.length) return { text, blocks } + } + return { + text, + blocks: [ + { + key: "initial", + mode: "full", + raw: text, + hash: checksum(text) ?? "", + html: fallback(text), + }, + ], + } +} + export function Markdown( props: ComponentProps<"div"> & { text: string @@ -251,51 +325,104 @@ export function Markdown( const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() + const owner = createUniqueId() + const activeCodeKeys = new Set() + const completedCode = new Map>() + const projection = createMemo((previous: Projection | undefined) => + project(previous, local.text, local.streaming ?? false), + ) const [html] = createResource( - () => ({ - text: local.text, - key: local.cacheKey, - streaming: local.streaming ?? false, - }), + () => { + return { + text: local.text, + key: local.cacheKey, + projection: projection(), + } + }, async (src) => { - if (isServer) return fallback(src.text) - if (!src.text) return "" + if (isServer) + return { + text: src.text, + blocks: [ + { + key: "server", + mode: "full" as const, + raw: src.text, + hash: checksum(src.text) ?? "", + html: fallback(src.text), + }, + ], + } satisfies RenderResult + if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult const base = src.key ?? checksum(src.text) return Promise.all( - stream(src.text, src.streaming).map(async (block, index) => { - const hash = checksum(block.raw) - const key = base ? `${base}:${index}:${block.mode}` : hash + src.projection.blocks.map(async (block, index) => { + const key = base ? `${base}:${index}:${block.mode}` : undefined + const blockKey = markdownBlockKey(owner, src.key, index, block.mode) - if (key && hash) { + if (block.mode === "code") { + const cached = completedCode.get(blockKey) + if (block.complete && cached?.raw === block.raw) return cached + const result = await code(block.src, block.language, blockKey, block.complete) + const rendered = { + key: blockKey, + mode: block.mode, + raw: block.raw, + hash: String(block.raw.length), + complete: !!block.complete, + ...result, + } + if (block.complete) completedCode.set(blockKey, rendered) + return rendered + } + + if (key) { const cached = cache.get(key) - if (cached && cached.hash === hash) { + if (cached?.raw === block.raw) { touch(key, cached) - return cached.html + return { key: blockKey, mode: block.mode, ...cached } } } - const next = await Promise.resolve(marked.parse(block.src)) - const safe = sanitize(next) - if (key && hash) touch(key, { hash, html: safe }) - return safe + const hash = checksum(block.raw) + const safe = sanitize(await Promise.resolve(marked.parse(block.src))) + if (key && hash) touch(key, { raw: block.raw, hash, html: safe }) + return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe } }), ) - .then((list) => list.join("")) - .catch(() => fallback(src.text)) + .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult) + .catch( + () => + ({ + text: src.text, + blocks: [ + { + key: base ?? "fallback", + mode: "full" as const, + raw: src.text, + hash: checksum(src.text) ?? "", + html: fallback(src.text), + }, + ], + }) satisfies RenderResult, + ) + }, + { + initialValue: initialResult(local.text, local.cacheKey, projection(), owner), }, - { initialValue: fallback(local.text) }, ) let copyCleanup: (() => void) | undefined createEffect(() => { const container = root() - const content = local.text ? (html.latest ?? html() ?? "") : "" + const result = html.latest ?? html() + const projected = projection() + const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : [] if (!container) return if (isServer) return - - if (!content) { + if (content.length === 0) { container.innerHTML = "" return } @@ -304,27 +431,17 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), } - const temp = document.createElement("div") - temp.innerHTML = content - decorate(temp, labels) - - morphdom(container, temp, { - childrenOnly: true, - onBeforeElUpdated: (fromEl, toEl) => { - if ( - fromEl instanceof HTMLButtonElement && - toEl instanceof HTMLButtonElement && - fromEl.getAttribute("data-slot") === "markdown-copy-button" && - toEl.getAttribute("data-slot") === "markdown-copy-button" && - fromEl.getAttribute("data-copied") === "true" - ) { - setCopyState(toEl, labels, true) - } - if (fromEl.isEqualNode(toEl)) return false - return true - }, + const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key)) + activeCodeKeys.forEach((key) => { + if (!nextCodeKeys.has(key)) disposeCode(key) }) - + activeCodeKeys.clear() + nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) + content.forEach((block, index) => updateBlock(container, index, block, labels)) + while (container.children.length > content.length) container.lastElementChild?.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"), @@ -334,6 +451,8 @@ export function Markdown( onCleanup(() => { if (copyCleanup) copyCleanup() + activeCodeKeys.forEach(disposeCode) + completedCode.clear() }) return ( @@ -348,3 +467,153 @@ export function Markdown( /> ) } + +function pendingBlocks( + result: RenderResult | undefined, + projection: Projection | undefined, + cacheKey: string | undefined, + owner: string, +) { + if (!result) return [] + if (!projection || result.text === projection.text) return result.blocks + const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial" + return projection.blocks.map((block, index) => { + const current = initial ? undefined : result.blocks[index] + if (current && canReusePendingBlock(current, block)) return current + const key = markdownBlockKey(owner, cacheKey, index, block.mode) + if (block.mode !== "code") + return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) } + return { + key, + mode: block.mode, + raw: block.raw, + hash: String(block.raw.length), + language: block.language ?? "text", + complete: !!block.complete, + stable: [], + generation: 0, + unstable: [[block.src, ""] as MarkdownToken], + } + }) +} + +function disposeCode(key: string) { + disposeStreamingCode(key) +} + +function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) { + const current = container.children[index] + if (block.mode === "code") { + updateCodeBlock(container, current, block, labels) + return + } + if ( + current instanceof HTMLDivElement && + current.dataset.markdownKey === block.key && + current.dataset.markdownHash === block.hash + ) + return + + const next = document.createElement("div") + next.dataset.markdownBlock = "" + next.dataset.markdownKey = block.key + next.dataset.markdownHash = block.hash + next.style.display = "contents" + next.innerHTML = block.html + decorate(next, labels) + + if (!(current instanceof HTMLDivElement)) { + container.appendChild(next) + return + } + + morphdom(current, next, { + onBeforeElUpdated: (fromEl, toEl) => { + if ( + fromEl instanceof HTMLButtonElement && + toEl instanceof HTMLButtonElement && + fromEl.getAttribute("data-slot") === "markdown-copy-button" && + toEl.getAttribute("data-slot") === "markdown-copy-button" + ) { + return false + } + if (fromEl.isEqualNode(toEl)) return false + return true + }, + }) +} + +function updateCodeBlock( + container: HTMLDivElement, + current: Element | undefined, + block: Extract, + labels: CopyLabels, +) { + const existing = + current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined + const next = existing ?? document.createElement("div") + next.dataset.markdownBlock = "" + next.dataset.markdownKey = block.key + next.dataset.markdownHash = block.hash + next.dataset.markdownComplete = block.complete ? "true" : "false" + next.style.display = "contents" + + const code = existing?.querySelector("code") + if (code instanceof HTMLElement) { + code.className = `language-${block.language}` + const previous = renderedCodeTokens.get(next) + const reset = shouldResetCodeTokens(previous, { + language: block.language, + generation: block.generation, + stableCount: block.stable.length, + raw: block.raw, + }) + const stableCount = reset ? 0 : previous!.stableCount + const tail = [...block.stable.slice(stableCount), ...block.unstable] + const prior = reset ? [] : previous!.unstable + const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index])) + const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix) + while (code.children.length > keep) code.lastElementChild?.remove() + tail.slice(keep - stableCount).map(createTokenSpan).forEach((span) => code.appendChild(span)) + renderedCodeTokens.set(next, { + language: block.language, + generation: block.generation, + stableCount: block.stable.length, + unstable: block.unstable, + raw: block.raw, + }) + return + } + + const wrapper = document.createElement("div") + wrapper.setAttribute("data-component", "markdown-code") + const pre = document.createElement("pre") + pre.className = "shiki OpenCode" + const codeElement = document.createElement("code") + codeElement.className = `language-${block.language}` + ;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span)) + pre.appendChild(codeElement) + wrapper.appendChild(pre) + wrapper.appendChild(createCopyButton(labels)) + next.appendChild(wrapper) + renderedCodeTokens.set(next, { + language: block.language, + generation: block.generation, + stableCount: block.stable.length, + unstable: block.unstable, + raw: block.raw, + }) + if (current) current.replaceWith(next) + else container.appendChild(next) +} + +function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) { + return !!right && left[0] === right[0] && left[1] === right[1] +} + +function createTokenSpan(token: MarkdownToken) { + const span = document.createElement("span") + span.setAttribute("style", token[1]) + span.textContent = token[0] + return span +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 35912e119d4..bbc3d2956cb 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -179,6 +179,7 @@ export interface MessagePartProps { onToolOpenChange?: (open: boolean) => void deferToolContent?: boolean virtualizeDiff?: boolean + onContentRendered?: () => void showAssistantCopyPartID?: string | null turnDurationMs?: number } @@ -188,13 +189,14 @@ export type PartComponent = Component export const PART_MAPPING: Record = {} const TEXT_RENDER_PACE_MS = 24 +const TEXT_RENDER_IMMEDIATE = 512 const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/ function step(size: number) { if (size <= 12) return 2 if (size <= 48) return 4 if (size <= 96) return 8 - return Math.min(24, Math.ceil(size / 8)) + return Math.min(256, Math.ceil(size / 4)) } function next(text: string, start: number) { @@ -233,6 +235,10 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { sync(text) return } + if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { + sync(text) + return + } const end = next(text, shown.length) sync(text.slice(0, end)) if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS) @@ -250,6 +256,11 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { sync(text) return } + if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { + clear() + sync(text) + return + } if (text.length === shown.length || timeout) return timeout = setTimeout(run, TEXT_RENDER_PACE_MS) }) @@ -1272,6 +1283,7 @@ export function Part(props: MessagePartProps) { onToolOpenChange={props.onToolOpenChange} deferToolContent={props.deferToolContent} virtualizeDiff={props.virtualizeDiff} + onContentRendered={props.onContentRendered} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} /> @@ -1292,6 +1304,7 @@ export interface ToolProps { onOpenChange?: (open: boolean) => void deferContent?: boolean virtualizeDiff?: boolean + onContentRendered?: () => void forceOpen?: boolean locked?: boolean } @@ -1438,6 +1451,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} deferContent={props.deferToolContent} virtualizeDiff={props.virtualizeDiff} + onContentRendered={props.onContentRendered} />
@@ -2021,7 +2035,13 @@ ToolRegistry.register({ } >
- +
@@ -2080,6 +2100,7 @@ ToolRegistry.register({ cacheKey: checksum(props.input.content), }} overflow="scroll" + onRendered={props.onContentRendered} /> @@ -2208,6 +2229,7 @@ ToolRegistry.register({ virtualize={props.virtualizeDiff} fileDiff={file.view.fileDiff} hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"} + onRendered={props.onContentRendered} /> @@ -2283,6 +2305,7 @@ ToolRegistry.register({ mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} + onRendered={props.onContentRendered} /> diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d28b51fea8b..370d025aa69 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { scrollKey } from "./scroll-view" +import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view" describe("scrollKey", () => { test("maps plain navigation keys", () => { @@ -17,3 +17,38 @@ describe("scrollKey", () => { expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined() }) }) + +describe("scrollTopFromThumbPointer", () => { + test("keeps downward thumb movement monotonic when content height changes", () => { + const first = scrollTopFromThumbPointer({ + pointer: 300, + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 6_000, + thumbHeight: 60, + }) + const second = scrollTopFromThumbPointer({ + pointer: 320, + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 60_000, + thumbHeight: 32, + }) + + expect(second).toBeGreaterThan(first) + }) + + test("clamps pointer positions to the scroll range", () => { + const input = { + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 6_000, + thumbHeight: 60, + } + expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) + expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 3ff00f117d2..f8a4f18b7ea 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -27,6 +27,24 @@ export const scrollKey = (event: Pick { e.preventDefault() e.stopPropagation() setState("isDragging", true) - startY = e.clientY - startScrollTop = viewportRef.scrollTop + const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top thumbRef.setPointerCapture(e.pointerId) const onPointerMove = (e: PointerEvent) => { - const deltaY = e.clientY - startY const { scrollHeight, clientHeight } = viewportRef - const maxScrollTop = scrollHeight - clientHeight - const maxThumbTop = clientHeight - thumbHeight() - - if (maxThumbTop > 0) { - const scrollDelta = deltaY * (maxScrollTop / maxThumbTop) - viewportRef.scrollTop = startScrollTop + scrollDelta - } + viewportRef.scrollTop = scrollTopFromThumbPointer({ + pointer: e.clientY, + viewportTop: viewportRef.getBoundingClientRect().top, + grabOffset, + clientHeight, + scrollHeight, + thumbHeight: thumbHeight(), + }) } - const onPointerUp = (e: PointerEvent) => { + const done = (e: PointerEvent) => { setState("isDragging", false) thumbRef.releasePointerCapture(e.pointerId) thumbRef.removeEventListener("pointermove", onPointerMove) - thumbRef.removeEventListener("pointerup", onPointerUp) + thumbRef.removeEventListener("pointerup", done) + thumbRef.removeEventListener("pointercancel", done) } thumbRef.addEventListener("pointermove", onPointerMove) - thumbRef.addEventListener("pointerup", onPointerUp) + thumbRef.addEventListener("pointerup", done) + thumbRef.addEventListener("pointercancel", done) } // Keybinds implementation diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 46f4993babd..762daaf29e1 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -6,14 +6,16 @@ import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" -registerCustomTheme("OpenCode", () => { - return Promise.resolve({ +export const OpenCodeTheme = { name: "OpenCode", + bg: "var(--color-background-stronger)", + fg: "var(--text-base)", colors: { "editor.background": "var(--color-background-stronger)", "editor.foreground": "var(--text-base)", "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", // "gitDecoration.conflictingResourceForeground": "#ffca00", // "gitDecoration.modifiedResourceForeground": "#1a76d4", // "gitDecoration.untrackedResourceForeground": "#00cab1", @@ -373,8 +375,9 @@ registerCustomTheme("OpenCode", () => { "variable.constant": "var(--syntax-constant)", "variable.defaultLibrary": "var(--syntax-unknown)", }, - } as unknown as ThemeRegistrationResolved) -}) +} as unknown as ThemeRegistrationResolved + +registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme)) function renderMathInText(text: string): string { let result = text diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 22586f0f583..7ef3ffb0334 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -17,24 +17,15 @@ export type DiffProps = FileDiffOptions & { const unsafeCSS = ` [data-diff], [data-file] { - --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); - --diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)))); - --diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)))); - --diffs-bg-context: var(--diffs-bg-context-override, light-dark( color-mix(in lab, var(--diffs-bg) 98.5%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92.5%, var(--diffs-mixer)))); - --diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer)))); - --diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); - --diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)))); - --diffs-deletion-base: var(--syntax-diff-delete); - --diffs-addition-base: var(--syntax-diff-add); - --diffs-modified-base: var(--syntax-diff-unknown); - --diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-emphasis: var(--diffs-bg-deletion-emphasis-override, light-dark(rgb(from var(--diffs-deletion-base) r g b / 0.7), rgb(from var(--diffs-deletion-base) r g b / 0.1))); - --diffs-bg-addition: var(--diffs-bg-addition-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base)))); - --diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base)))); - --diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base)))); - --diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1))); + /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */ + --diffs-bg-deletion-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base)) + ); + --diffs-bg-addition-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base)) + ); --diffs-selection-base: var(--surface-warning-strong); --diffs-selection-border: var(--border-warning-base); --diffs-selection-number-fg: #1c1917; diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 31862cc4931..235a3fd6773 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -16,7 +16,7 @@ const cache = new WeakMap() export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, - fileGap: 0, + spacing: 0, } function scrollable(value: string) { diff --git a/patches/@tanstack%2Fsolid-virtual@3.13.28.patch b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch new file mode 100644 index 00000000000..3b1cda91e1c --- /dev/null +++ b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch @@ -0,0 +1,45 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index 7e97823ea769398ccd9cf449b178c77675ed252c..d75183f11421af0e20e4e8a996af99c300ad936d 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -39,7 +39,9 @@ function createVirtualizerBase(options) { + (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); + } + })); +- virtualizer.measure(); ++ virtualizer._willUpdate(); ++ setVirtualItems(store.reconcile(instance.getVirtualItems(), { key: "index" })); ++ setTotalSize(instance.getTotalSize()); + }); + return virtualizer; + } +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 1d525463775fef3e8ece6ab191061ef9d0a36d73..14c680a2088c49a33959d8118cf32ee599ab83c2 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -38,7 +38,9 @@ function createVirtualizerBase(options) { + (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); + } + })); +- virtualizer.measure(); ++ virtualizer._willUpdate(); ++ setVirtualItems(reconcile(instance.getVirtualItems(), { key: "index" })); ++ setTotalSize(instance.getTotalSize()); + }); + return virtualizer; + } +diff --git a/src/index.tsx b/src/index.tsx +index 69ac34fd70753b9bd00683c2540be7f62630f8f2..9f16672aa0f4a044aa2b35754d385d7d8031f743 100644 +--- a/src/index.tsx ++++ b/src/index.tsx +@@ -81,7 +81,9 @@ function createVirtualizerBase< + }, + }), + ) +- virtualizer.measure() ++ virtualizer._willUpdate() ++ setVirtualItems(reconcile(instance.getVirtualItems(), { key: 'index' })) ++ setTotalSize(instance.getTotalSize()) + }) + + return virtualizer diff --git a/patches/@tanstack%2Fvirtual-core@3.17.0.patch b/patches/@tanstack%2Fvirtual-core@3.17.0.patch new file mode 100644 index 00000000000..388f8855a53 --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.0.patch @@ -0,0 +1,58 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index df75d0cf0347b62906e04e454d4f4ef062ed5c48..58913f2d30e0beee9d09dffa5ebcaab4601a2c22 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -526,6 +526,7 @@ class Virtualizer { + this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); + return this.scrollOffset; + }; ++ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; + this.getFurthestMeasurement = (measurements, index) => { + const furthestMeasurementsFound = /* @__PURE__ */ new Map(); + const furthestMeasurements = /* @__PURE__ */ new Map(); +diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts +index c61ee17752565253f795c7fc7d57e86237ecbb52..705bb7e3a121b040fb1a3e7890179eaa3e9b219e 100644 +--- a/dist/cjs/index.d.cts ++++ b/dist/cjs/index.d.cts +@@ -108,6 +108,7 @@ export declare class Virtualizer number; + private scrollAdjustments; + private _iosDeferredAdjustment; + private _iosTouching; +diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts +index b03abab604eb6578f6f56ff92c489259cfaf8f19..0495f372ea000dffc416c4f56809946f7ba73099 100644 +--- a/dist/esm/index.d.ts ++++ b/dist/esm/index.d.ts +@@ -108,6 +108,7 @@ export declare class Virtualizer number; + private scrollAdjustments; + private _iosDeferredAdjustment; + private _iosTouching; +diff --git a/dist/esm/index.js b/dist/esm/index.js +index e384cf7541978a2782b9dca68146e869b16ac3f2..53b15b7a36247ea4aa5b70b00d08b6d7dec3ea79 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -524,6 +524,7 @@ class Virtualizer { + this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); + return this.scrollOffset; + }; ++ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; + this.getFurthestMeasurement = (measurements, index) => { + const furthestMeasurementsFound = /* @__PURE__ */ new Map(); + const furthestMeasurements = /* @__PURE__ */ new Map(); +diff --git a/src/index.ts b/src/index.ts +index d35b3e0695a9c85b261bc1a4fbe23c0a60d5b204..d516a9d00f1233173f9a3e2144666c8159552f91 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1050,3 +1050,5 @@ export class Virtualizer< ++ getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments ++ + private getFurthestMeasurement = ( + measurements: Array, + index: number, diff --git a/patches/virtua@0.49.1.patch b/patches/virtua@0.49.1.patch deleted file mode 100644 index d8064d3d010..00000000000 --- a/patches/virtua@0.49.1.patch +++ /dev/null @@ -1,93 +0,0 @@ -diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts -index 144dd7f..819aab9 100644 ---- a/lib/solid/Virtualizer.d.ts -+++ b/lib/solid/Virtualizer.d.ts -@@ -38,6 +38,10 @@ export interface VirtualizerHandle { - * @param index index of item - */ - getItemSize(index: number): number; -+ /** -+ * Synchronously measure currently mounted items and update cached item sizes. -+ */ -+ measure(): void; - /** - * Scroll to the item specified by index. - * @param index index of item -diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx -index 029201a..3949cd4 100644 ---- a/lib/solid/index.jsx -+++ b/lib/solid/index.jsx -@@ -1085,6 +1085,7 @@ const createResizer = (store, isHorizontal) => { - let viewportElement; - const sizeKey = isHorizontal ? "width" : "height"; - const mountedIndexes = new WeakMap(); -+ const mountedItems = new Map(); - const resizeObserver = createResizeObserver((entries) => { - const resizes = []; - for (const { target, contentRect } of entries) { -@@ -1111,12 +1112,27 @@ const createResizer = (store, isHorizontal) => { - }, - $observeItem: (el, i) => { - mountedIndexes.set(el, i); -+ mountedItems.set(i, el); - resizeObserver._observe(el); - return () => { - mountedIndexes.delete(el); -+ if (mountedItems.get(i) === el) { -+ mountedItems.delete(i); -+ } - resizeObserver._unobserve(el); - }; - }, -+ $measureItems: () => { -+ const resizes = []; -+ mountedItems.forEach((el, index) => { -+ if (!el.offsetParent) -+ return; -+ resizes.push([index, el.getBoundingClientRect()[sizeKey]]); -+ }); -+ if (resizes.length) { -+ store.$update(ACTION_ITEM_RESIZE, resizes); -+ } -+ }, - $dispose: resizeObserver._dispose, - }; - }; -@@ -1354,6 +1370,8 @@ const Virtualizer = (props) => { - const range = createMemo((prev) => { - stateVersion(); - const next = store.$getRange(props.bufferSize); -+ next[0] = Math.max(0, next[0]); -+ next[1] = Math.min(props.data.length - 1, next[1]); - if (prev && isSameRange(prev, next)) { - return prev; - } -@@ -1380,6 +1398,7 @@ const Virtualizer = (props) => { - findItemIndex: store.$findItemIndex, - getItemOffset: store.$getItemOffset, - getItemSize: store.$getItemSize, -+ measure: resizer.$measureItems, - scrollToIndex: scroller.$scrollToIndex, - scrollTo: scroller.$scrollTo, - scrollBy: scroller.$scrollBy, -@@ -1417,6 +1436,11 @@ const Virtualizer = (props) => { - const indexes = []; - if (props.keepMounted) { - const mounted = new Set(props.keepMounted); -+ mounted.forEach((index) => { -+ if (index < 0 || index >= count) { -+ mounted.delete(index); -+ } -+ }); - for (let [i, j] = range(); i <= j; i++) { - mounted.add(i); - } -@@ -1528,6 +1552,8 @@ const WindowVirtualizer = (props) => { - const range = createMemo((prev) => { - stateVersion(); - const next = store.$getRange(props.bufferSize); -+ next[0] = Math.max(0, next[0]); -+ next[1] = Math.min(props.data.length - 1, next[1]); - if (prev && isSameRange(prev, next)) { - return prev; - } From 273efdeee755cf8dc83298e186859be071a414c5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 16 Jun 2026 16:57:04 +0000 Subject: [PATCH 006/335] chore: generate --- bun.lock | 18 +- .../session-timeline-collapse-state.spec.ts | 9 +- .../app/e2e/smoke/session-timeline.spec.ts | 91 ++- .../session/timeline/message-timeline.tsx | 5 +- .../app/src/pages/session/timeline/model.ts | 6 +- .../src/pages/session/timeline/projection.ts | 9 +- .../ui/src/components/markdown-stream.test.ts | 8 +- .../markdown-worker-protocol.test.ts | 6 +- packages/ui/src/components/markdown.tsx | 25 +- packages/ui/src/components/scroll-view.tsx | 5 +- packages/ui/src/context/marked.tsx | 730 +++++++++--------- 11 files changed, 467 insertions(+), 445 deletions(-) diff --git a/bun.lock b/bun.lock index 34793cde8d4..9bd91760b96 100644 --- a/bun.lock +++ b/bun.lock @@ -905,26 +905,26 @@ }, }, "trustedDependencies": [ + "esbuild", "tree-sitter-powershell", + "protobufjs", + "electron", "web-tree-sitter", "tree-sitter-bash", - "esbuild", - "electron", - "protobufjs", ], "patchedDependencies": { - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index c902e7faf38..7935b10014b 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -196,7 +196,8 @@ test.describe("regression: session timeline local row state", () => { const events: EventPayload[] = [] const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("") const after = [100, 300, 500, 700, 900].reduce( - (result, index) => result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`), + (result, index) => + result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`), lines, ) const part = { @@ -385,11 +386,7 @@ function readExpanded(element: Element) { return !!content && content.getBoundingClientRect().height > 0 } -async function mockServer( - page: Page, - events: EventPayload[], - messages = [userMessage, assistantMessage], -) { +async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) { await mockOpenCodeServer(page, { directory, project: project(), diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index de5eeb20ca5..925614cc28f 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -172,7 +172,9 @@ test.describe("smoke: session timeline", () => { }) .map((element) => element.dataset.messageId!) .filter((id) => ids.has(id)) - const bottom = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + const bottom = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { firstPaint = true @@ -187,9 +189,11 @@ test.describe("smoke: session timeline", () => { requestAnimationFrame(sample) }, 0) } - ;(window as Window & { - __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } - }).__sessionTabPaint = { + ;( + window as Window & { + __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + } + ).__sessionTabPaint = { samples, removed: () => removedFirstPaintNodes, stop: () => { @@ -203,19 +207,21 @@ test.describe("smoke: session timeline", () => { await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await page.waitForFunction(() => - (window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some( - (sample) => sample.ids.length > 0, - ), + ( + window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } } + ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0), ) await page.waitForTimeout(200) const first = await page.evaluate(() => { - const probe = (window as Window & { - __sessionTabPaint?: { - samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> - removed: () => number - stop: () => void + const probe = ( + window as Window & { + __sessionTabPaint?: { + samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> + removed: () => number + stop: () => void + } } - }).__sessionTabPaint! + ).__sessionTabPaint! probe.stop() return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } }) @@ -253,31 +259,36 @@ test.describe("smoke: session timeline", () => { await expectSessionTitle(page, fixture.expected.sourceTitle) const last = fixture.expected.targetMessageIDs.at(-1)! const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) - await page.evaluate(({ destination, last }) => { - const ids = new Set(destination) - const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] - const sample = () => { - const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-row]"), - ) - if (root) { - const view = root.getBoundingClientRect() - const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() - const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { - const rect = element.getBoundingClientRect() - return rect.bottom > view.top && rect.top < view.bottom - }) - samples.push({ - destination: messages.some((element) => ids.has(element.dataset.messageId!)), - last: messages.some((element) => element.dataset.messageId === last), - bottomError: spacer ? spacer.bottom - view.bottom : undefined, - }) + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] + const sample = () => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + samples.push({ + destination: messages.some((element) => ids.has(element.dataset.messageId!)), + last: messages.some((element) => element.dataset.messageId === last), + bottomError: spacer ? spacer.bottom - view.bottom : undefined, + }) + } + requestAnimationFrame(() => setTimeout(sample, 0)) } + ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples requestAnimationFrame(() => setTimeout(sample, 0)) - } - ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples - requestAnimationFrame(() => setTimeout(sample, 0)) - }, { destination, last }) + }, + { destination, last }, + ) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await page.waitForFunction(() => @@ -286,9 +297,11 @@ test.describe("smoke: session timeline", () => { ), ) const result = await page.evaluate(() => { - const samples = (window as Window & { - __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> - }).__coldTabSamples! + const samples = ( + window as Window & { + __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> + } + ).__coldTabSamples! return samples.find((sample) => sample.destination)! }) expect(result.last).toBe(true) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 5c9de3d932f..5a26f178163 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -82,10 +82,7 @@ type FramedTimelineRow = Exclude type TimelineRowByTag = Extract const timelineFallbackItemSize = 60 -const timelineCache = new Map< - string, - { measurements: VirtualItem[]; toolOpen: Record } ->() +const timelineCache = new Map }>() const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 0514e497559..7154827f907 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -55,11 +55,7 @@ export function createTimelineModel(input: { const id = input.sessionID() return !id || sync().data.message[id] !== undefined }) - const userMessages = createMemo( - () => selectUserMessages(messages()), - emptyUserMessages, - { equals: same }, - ) + const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same }) const visibleUserMessages = createMemo( () => { return selectVisibleUserMessages(userMessages(), input.revertMessageID()) diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index dfa924923dd..8c84899004f 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -27,9 +27,12 @@ export function createTimelineProjection(input: { return result }) const activeMessageID = createMemo(() => { - const parentID = input.messages().findLast( - (message): message is AssistantMessage => message.role === "assistant" && typeof message.time.completed !== "number", - )?.parentID + const parentID = input + .messages() + .findLast( + (message): message is AssistantMessage => + message.role === "assistant" && typeof message.time.completed !== "number", + )?.parentID if (parentID) { const messages = input.messages() const result = Binary.search(messages, parentID, (message) => message.id) diff --git a/packages/ui/src/components/markdown-stream.test.ts b/packages/ui/src/components/markdown-stream.test.ts index 16727861046..e792a338396 100644 --- a/packages/ui/src/components/markdown-stream.test.ts +++ b/packages/ui/src/components/markdown-stream.test.ts @@ -123,8 +123,12 @@ describe("markdown stream", () => { }) test("only reuses pending blocks with compatible identity and content", () => { - expect(canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "", })).toBe(false) - expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" })).toBe(true) + expect( + canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "" }), + ).toBe(false) + expect( + canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }), + ).toBe(true) expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false) }) diff --git a/packages/ui/src/components/markdown-worker-protocol.test.ts b/packages/ui/src/components/markdown-worker-protocol.test.ts index 0c84ac557a6..45a169c6a28 100644 --- a/packages/ui/src/components/markdown-worker-protocol.test.ts +++ b/packages/ui/src/components/markdown-worker-protocol.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test" -import { applyMarkdownWorkerResponse, markdownBlockKey, shouldReleaseMarkdownWorkerState } from "./markdown-worker-protocol" +import { + applyMarkdownWorkerResponse, + markdownBlockKey, + shouldReleaseMarkdownWorkerState, +} from "./markdown-worker-protocol" const token = (content: string): [string, string] => [content, ""] const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({ diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 018a6a7ddbd..78cff8abde4 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -3,7 +3,16 @@ import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" -import { ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, splitProps } from "solid-js" +import { + ComponentProps, + createEffect, + createMemo, + createResource, + createSignal, + createUniqueId, + onCleanup, + splitProps, +} from "solid-js" import { isServer } from "solid-js/web" import { bundledLanguages } from "shiki" import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream" @@ -439,9 +448,9 @@ export function Markdown( nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) content.forEach((block, index) => updateBlock(container, index, block, labels)) while (container.children.length > content.length) container.lastElementChild?.remove() - container.querySelectorAll('[data-slot="markdown-copy-button"]').forEach((button) => - setCopyState(button, labels, button.dataset.copied === "true"), - ) + 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"), @@ -549,8 +558,7 @@ function updateCodeBlock( block: Extract, labels: CopyLabels, ) { - const existing = - current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined + const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined const next = existing ?? document.createElement("div") next.dataset.markdownBlock = "" next.dataset.markdownKey = block.key @@ -574,7 +582,10 @@ function updateCodeBlock( const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index])) const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix) while (code.children.length > keep) code.lastElementChild?.remove() - tail.slice(keep - stableCount).map(createTokenSpan).forEach((span) => code.appendChild(span)) + tail + .slice(keep - stableCount) + .map(createTokenSpan) + .forEach((span) => code.appendChild(span)) renderedCodeTokens.set(next, { language: block.language, generation: block.generation, diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index f8a4f18b7ea..9944da77d3a 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -38,10 +38,7 @@ export function scrollTopFromThumbPointer(input: { const padding = 8 const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight if (maxThumbTop <= 0) return 0 - const thumbTop = Math.max( - 0, - Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop), - ) + const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight) } diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 762daaf29e1..e93c74a0cb0 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -7,374 +7,374 @@ import { createSimpleContext } from "./helper" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" export const OpenCodeTheme = { - name: "OpenCode", - bg: "var(--color-background-stronger)", - fg: "var(--text-base)", - colors: { - "editor.background": "var(--color-background-stronger)", - "editor.foreground": "var(--text-base)", - "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", - "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", - "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", - // "gitDecoration.conflictingResourceForeground": "#ffca00", - // "gitDecoration.modifiedResourceForeground": "#1a76d4", - // "gitDecoration.untrackedResourceForeground": "#00cab1", - // "gitDecoration.ignoredResourceForeground": "#84848A", - // "terminal.titleForeground": "#adadb1", - // "terminal.titleInactiveForeground": "#84848A", - // "terminal.background": "#141415", - // "terminal.foreground": "#adadb1", - // "terminal.ansiBlack": "#141415", - // "terminal.ansiRed": "#ff2e3f", - // "terminal.ansiGreen": "#0dbe4e", - // "terminal.ansiYellow": "#ffca00", - // "terminal.ansiBlue": "#008cff", - // "terminal.ansiMagenta": "#c635e4", - // "terminal.ansiCyan": "#08c0ef", - // "terminal.ansiWhite": "#c6c6c8", - // "terminal.ansiBrightBlack": "#141415", - // "terminal.ansiBrightRed": "#ff2e3f", - // "terminal.ansiBrightGreen": "#0dbe4e", - // "terminal.ansiBrightYellow": "#ffca00", - // "terminal.ansiBrightBlue": "#008cff", - // "terminal.ansiBrightMagenta": "#c635e4", - // "terminal.ansiBrightCyan": "#08c0ef", - // "terminal.ansiBrightWhite": "#c6c6c8", + name: "OpenCode", + bg: "var(--color-background-stronger)", + fg: "var(--text-base)", + colors: { + "editor.background": "var(--color-background-stronger)", + "editor.foreground": "var(--text-base)", + "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", + "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", + // "gitDecoration.conflictingResourceForeground": "#ffca00", + // "gitDecoration.modifiedResourceForeground": "#1a76d4", + // "gitDecoration.untrackedResourceForeground": "#00cab1", + // "gitDecoration.ignoredResourceForeground": "#84848A", + // "terminal.titleForeground": "#adadb1", + // "terminal.titleInactiveForeground": "#84848A", + // "terminal.background": "#141415", + // "terminal.foreground": "#adadb1", + // "terminal.ansiBlack": "#141415", + // "terminal.ansiRed": "#ff2e3f", + // "terminal.ansiGreen": "#0dbe4e", + // "terminal.ansiYellow": "#ffca00", + // "terminal.ansiBlue": "#008cff", + // "terminal.ansiMagenta": "#c635e4", + // "terminal.ansiCyan": "#08c0ef", + // "terminal.ansiWhite": "#c6c6c8", + // "terminal.ansiBrightBlack": "#141415", + // "terminal.ansiBrightRed": "#ff2e3f", + // "terminal.ansiBrightGreen": "#0dbe4e", + // "terminal.ansiBrightYellow": "#ffca00", + // "terminal.ansiBrightBlue": "#008cff", + // "terminal.ansiBrightMagenta": "#c635e4", + // "terminal.ansiBrightCyan": "#08c0ef", + // "terminal.ansiBrightWhite": "#c6c6c8", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment", "string.comment"], + settings: { + foreground: "var(--syntax-comment)", + }, }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment", "string.comment"], - settings: { - foreground: "var(--syntax-comment)", - }, + { + scope: ["entity.other.attribute-name"], + settings: { + foreground: "var(--syntax-property)", // maybe attribute }, - { - scope: ["entity.other.attribute-name"], - settings: { - foreground: "var(--syntax-property)", // maybe attribute - }, - }, - { - scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], - settings: { - foreground: "var(--syntax-constant)", - }, - }, - { - scope: ["entity.name", "meta.export.default", "meta.definition.variable"], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: ["meta.object.member"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: [ - "variable.parameter.function", - "meta.jsx.children", - "meta.block", - "meta.tag.attributes", - "entity.name.constant", - "meta.embedded.expression", - "meta.template.expression", - "string.other.begin.yaml", - "string.other.end.yaml", - ], - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["entity.name.function", "support.type.primitive"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: ["support.class.component"], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "keyword.operator", - "storage.type.function.arrow", - "punctuation.separator.key-value.css", - "entity.name.tag.yaml", - "punctuation.separator.key-value.mapping.yaml", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["storage", "storage.type"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: [ - "string", - "punctuation.definition.string", - "string punctuation.section.embedded source", - "entity.name.tag", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "support", - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], - settings: { - foreground: "var(--syntax-object)", - }, - }, - { - scope: "meta.property-name", - settings: { - foreground: "var(--syntax-property)", - }, - }, - { - scope: "variable", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "variable.other", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "invalid.broken", - "invalid.illegal", - "invalid.unimplemented", - "invalid.deprecated", - "message.error", - "markup.deleted", - "meta.diff.header.from-file", - "punctuation.definition.deleted", - "brackethighlighter.unmatched", - "token.error-token", - ], - settings: { - foreground: "var(--syntax-critical)", - }, - }, - { - scope: "carriage-return", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "string source", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "string variable", - settings: { - foreground: "var(--syntax-constant)", - }, - }, - { - scope: [ - "source.regexp", - "string.regexp", - "string.regexp.character-class", - "string.regexp constant.character.escape", - "string.regexp source.ruby.embedded", - "string.regexp string.regexp.arbitrary-repitition", - "string.regexp constant.character.escape", - ], - settings: { - foreground: "var(--syntax-regexp)", - }, - }, - { - scope: "support.constant", - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: "support.variable", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "meta.module-reference", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["markup.heading", "markup.heading entity.name"], - settings: { - fontStyle: "bold", - foreground: "var(--syntax-info)", - }, - }, - { - scope: "markup.quote", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - // foreground: "", - }, - }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--text-strong)", - }, - }, - { - scope: [ - "markup.raw", - "markup.inserted", - "meta.diff.header.to-file", - "punctuation.definition.inserted", - "markup.changed", - "punctuation.definition.changed", - "markup.ignored", - "markup.untracked", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "meta.diff.range", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.separator", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.output", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.export.default", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: [ - "brackethighlighter.tag", - "brackethighlighter.curly", - "brackethighlighter.round", - "brackethighlighter.square", - "brackethighlighter.angle", - "brackethighlighter.quote", - ], - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: ["constant.other.reference.link", "string.other.link"], - settings: { - fontStyle: "underline", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "token.info-token", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "token.warn-token", - settings: { - foreground: "var(--syntax-warning)", - }, - }, - { - scope: "token.debug-token", - settings: { - foreground: "var(--syntax-info)", - }, - }, - ], - semanticTokenColors: { - comment: "var(--syntax-comment)", - string: "var(--syntax-string)", - number: "var(--syntax-constant)", - regexp: "var(--syntax-regexp)", - keyword: "var(--syntax-keyword)", - variable: "var(--syntax-variable)", - parameter: "var(--syntax-variable)", - property: "var(--syntax-property)", - function: "var(--syntax-primitive)", - method: "var(--syntax-primitive)", - type: "var(--syntax-type)", - class: "var(--syntax-type)", - namespace: "var(--syntax-type)", - enumMember: "var(--syntax-primitive)", - "variable.constant": "var(--syntax-constant)", - "variable.defaultLibrary": "var(--syntax-unknown)", }, + { + scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: ["entity.name", "meta.export.default", "meta.definition.variable"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: ["meta.object.member"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.embedded.expression", + "meta.template.expression", + "string.other.begin.yaml", + "string.other.end.yaml", + ], + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["entity.name.function", "support.type.primitive"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.class.component"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: "keyword", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: [ + "keyword.operator", + "storage.type.function.arrow", + "punctuation.separator.key-value.css", + "entity.name.tag.yaml", + "punctuation.separator.key-value.mapping.yaml", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: ["storage", "storage.type"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source", + "entity.name.tag", + ], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "support", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], + settings: { + foreground: "var(--syntax-object)", + }, + }, + { + scope: "meta.property-name", + settings: { + foreground: "var(--syntax-property)", + }, + }, + { + scope: "variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "variable.other", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: [ + "invalid.broken", + "invalid.illegal", + "invalid.unimplemented", + "invalid.deprecated", + "message.error", + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted", + "brackethighlighter.unmatched", + "token.error-token", + ], + settings: { + foreground: "var(--syntax-critical)", + }, + }, + { + scope: "carriage-return", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "string source", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "string variable", + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: [ + "source.regexp", + "string.regexp", + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition", + "string.regexp constant.character.escape", + ], + settings: { + foreground: "var(--syntax-regexp)", + }, + }, + { + scope: "support.constant", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: "support.variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "meta.module-reference", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "punctuation.definition.list.begin.markdown", + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["markup.heading", "markup.heading entity.name"], + settings: { + fontStyle: "bold", + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.quote", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.italic", + settings: { + fontStyle: "italic", + // foreground: "", + }, + }, + { + scope: "markup.bold", + settings: { + fontStyle: "bold", + foreground: "var(--text-strong)", + }, + }, + { + scope: [ + "markup.raw", + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted", + "markup.changed", + "punctuation.definition.changed", + "markup.ignored", + "markup.untracked", + ], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "meta.diff.range", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.diff.header", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.separator", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.output", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.export.default", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote", + ], + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: ["constant.other.reference.link", "string.other.link"], + settings: { + fontStyle: "underline", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "token.info-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "token.warn-token", + settings: { + foreground: "var(--syntax-warning)", + }, + }, + { + scope: "token.debug-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + ], + semanticTokenColors: { + comment: "var(--syntax-comment)", + string: "var(--syntax-string)", + number: "var(--syntax-constant)", + regexp: "var(--syntax-regexp)", + keyword: "var(--syntax-keyword)", + variable: "var(--syntax-variable)", + parameter: "var(--syntax-variable)", + property: "var(--syntax-property)", + function: "var(--syntax-primitive)", + method: "var(--syntax-primitive)", + type: "var(--syntax-type)", + class: "var(--syntax-type)", + namespace: "var(--syntax-type)", + enumMember: "var(--syntax-primitive)", + "variable.constant": "var(--syntax-constant)", + "variable.defaultLibrary": "var(--syntax-unknown)", + }, } as unknown as ThemeRegistrationResolved registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme)) From 8fd5753f7683a69dd1ce27aee850ceb178170d32 Mon Sep 17 00:00:00 2001 From: Keefe Tang Date: Wed, 17 Jun 2026 03:04:38 +1000 Subject: [PATCH 007/335] fix(provider): pass apiKey to createUnified for Cloudflare AI Gateway (#32052) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/core/src/plugin/provider/cloudflare-ai-gateway.ts | 2 +- packages/opencode/src/provider/provider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afb3a183e50..ba7856b6357 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ apiKey: config.apiKey, options: gatewayOptions(evt.options, metadata), } as any) - const unified = createUnified() + const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { languageModel(modelID: string) { return gateway(unified(modelID)) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4352f8a9b51..63ad8d0d7f6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -816,7 +816,7 @@ function custom(dep: CustomDep): Record { apiKey: apiToken, ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), }) - const unified = createUnified() + const unified = createUnified({ apiKey: apiToken }) return { autoload: true, From 3ab3d04ec7569717a965d4e95904e546b23ad575 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 16 Jun 2026 17:12:18 +0000 Subject: [PATCH 008/335] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 3b8dfa532b9..b7231c92abc 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-FMYhOcNXMhQLg1TIgmw45f6WBySCZV6pMrrMajttQ84=", - "aarch64-linux": "sha256-b3Jlln9hyG1AFZcgbNIrhpotYa4HFpWQU41uvMReWHw=", - "aarch64-darwin": "sha256-qJApAzbQz1nEufwUBhpgD2uoRfPz9cDFlveNZXFvNRM=", - "x86_64-darwin": "sha256-3NC69JtMIqqojcQrbcpEg4TzY0bv36Rr9Zg9oAD+dt8=" + "x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=", + "aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=", + "aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=", + "x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY=" } } From 213ff3f2d77fabfcfe3148bc21d7aa607e633737 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Tue, 16 Jun 2026 20:00:58 -0400 Subject: [PATCH 009/335] fix(opencode): sanitize OpenAI MCP tool schemas (#32489) Co-authored-by: jquense Co-authored-by: Aiden Cline Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 95 +++++++++ .../opencode/test/provider/transform.test.ts | 201 ++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 027efc0974b..92ff8fece83 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1286,6 +1286,96 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T return Math.min(model.limit.output, outputTokenMax) || outputTokenMax } +type JsonRecord = Record + +function isPlainObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas. +function sanitizeOpenAISchema(value: unknown): unknown { + const types = ["string", "number", "boolean", "integer", "object", "array", "null"] + const compositionKeys = ["anyOf", "oneOf", "allOf"] + + // JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas. + if (typeof value === "boolean") return { type: "string" } + if (Array.isArray(value)) return value.map(sanitizeOpenAISchema) + if (!isPlainObject(value)) return value + + const result: JsonRecord = {} + + if (typeof value.$ref === "string") result.$ref = value.$ref + if (typeof value.description === "string") result.description = value.description + if ("const" in value) result.enum = [value.const] + else if (Array.isArray(value.enum)) result.enum = value.enum + + if (isPlainObject(value.properties)) { + result.properties = Object.fromEntries( + Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]), + ) + } + + if (Array.isArray(value.required)) { + result.required = value.required.filter((item) => typeof item === "string") + } + + if ("items" in value) result.items = sanitizeOpenAISchema(value.items) + + if ("additionalProperties" in value) { + result.additionalProperties = + typeof value.additionalProperties === "boolean" + ? value.additionalProperties + : sanitizeOpenAISchema(value.additionalProperties) + } + + for (const key of compositionKeys) { + if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema) + } + + for (const key of ["$defs", "definitions"]) { + if (isPlainObject(value[key])) { + result[key] = Object.fromEntries( + Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]), + ) + } + } + + const schemaTypes = + typeof value.type === "string" + ? types.includes(value.type) + ? [value.type] + : [] + : Array.isArray(value.type) + ? value.type.filter((item) => typeof item === "string" && types.includes(item)) + : [] + + if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) { + return result + } + + // MCP schemas may omit `type` while still using keywords that imply one. + // Keep the schema usable after unsupported keywords are dropped. + const inferredTypes = + schemaTypes.length > 0 + ? schemaTypes + : ["properties", "required", "additionalProperties"].some((key) => key in value) + ? ["object"] + : ["items", "prefixItems"].some((key) => key in value) + ? ["array"] + : "enum" in result || "format" in value + ? ["string"] + : ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value) + ? ["number"] + : [] + + if (inferredTypes.length === 0) return {} + + result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes + if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {} + if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" } + return result +} + export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { @@ -1305,6 +1395,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + schema = sanitizeOpenAISchema(schema) as JSONSchema7 + // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. + } + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { const sanitizeMoonshot = (obj: unknown): unknown => { if (obj === null || typeof obj !== "object") return obj diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c23a2aa9995..04d75bfba27 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1154,6 +1154,207 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - openai supported schema subset", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4.1", + npm: "@ai-sdk/openai", + }, + } as any + + test("removes unsupported JSON Schema keywords recursively", () => { + const result = ProviderTransform.schema(openaiModel, { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Search", + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + format: "uri", + pattern: "^https://", + minLength: 1, + maxLength: 100, + default: "https://example.com", + }, + count: { + type: "integer", + minimum: 1, + maximum: 10, + multipleOf: 1, + }, + createdAt: { + format: "date-time", + }, + mode: { + const: "fast", + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + }, + tuple: { + type: "array", + items: [ + { type: "number", minimum: 0 }, + { type: "string", pattern: "^ok$" }, + ], + }, + metadata: { + type: "object", + patternProperties: { + "^x-": { type: "string" }, + }, + additionalProperties: { + type: "string", + pattern: "^safe$", + }, + }, + }, + patternProperties: { + "^extra": { type: "string" }, + }, + required: ["query"], + additionalProperties: false, + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + }, + count: { + type: "integer", + }, + createdAt: { + type: "string", + }, + mode: { + enum: ["fast"], + type: "string", + }, + tags: { + type: "array", + items: { type: "string" }, + }, + tuple: { + type: "array", + items: [{ type: "number" }, { type: "string" }], + }, + metadata: { + type: "object", + properties: {}, + additionalProperties: { + type: "string", + }, + }, + }, + required: ["query"], + additionalProperties: false, + }) + }) + + test("keeps local references and sanitizes definitions", () => { + const result = ProviderTransform.schema(openaiModel, { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Referenced value", + examples: ["ignored"], + }, + }, + $defs: { + Value: { + type: "string", + pattern: "^value$", + description: "Definition description", + }, + Unused: { + type: "number", + minimum: 0, + }, + }, + } as any) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + description: "Referenced value", + }) + expect(result.$defs).toEqual({ + Value: { + type: "string", + description: "Definition description", + }, + Unused: { + type: "number", + }, + }) + }) + + test("does not sanitize non-openai providers", () => { + const result = ProviderTransform.schema( + { + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/anthropic", + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ) as any + + expect(result.properties.query.pattern).toBe("^https://") + }) + + test.each([ + ["opencode", "@ai-sdk/openai"], + ["custom-openai-compatible", "@ai-sdk/openai"], + ["azure", "@ai-sdk/azure"], + ])("sanitizes %s models using %s", (providerID, npm) => { + expect( + ProviderTransform.schema( + { + providerID, + api: { + id: "custom-model", + npm, + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ), + ).toEqual({ + type: "object", + properties: { + query: { + type: "string", + }, + }, + }) + }) +}) + describe("ProviderTransform.schema - moonshot $ref siblings", () => { const moonshotModel = { providerID: "moonshotai", From 1e63e76192b1e93ddcac98165863bd72fe508a3c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 17 Jun 2026 04:02:08 -0500 Subject: [PATCH 010/335] fix(stats): scope model pages to go --- .../stats/app/src/routes/[lab]/[model].tsx | 22 +++++++++--------- packages/stats/core/src/domain/home.ts | 23 ------------------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 2accce52977..746d81ec2f5 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -123,8 +123,8 @@ export default function StatsModel() { const modelTitle = createMemo(() => `${modelName()} Data`) const modelDescription = createMemo(() => stats() - ? `${modelName()} usage, rank, token mix, cost, geo breakdown, and peer data across OpenCode.` - : `${modelName()} model facts, limits, and OpenCode usage availability.`, + ? `${modelName()} usage, rank, token mix, cost, geo breakdown, and peer data across OpenCode Go.` + : `${modelName()} model facts, limits, and OpenCode Go usage availability.`, ) const modelUrl = createMemo(() => new URL( @@ -257,7 +257,7 @@ function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEn Model facts from the shared model index. OpenCode usage appears once this model has activity.

+

Model facts from the shared model index. OpenCode Go usage appears once this model has activity.

} > {(data) => ( @@ -298,7 +298,7 @@ function ModelCatalogCallout(props: { catalog: ModelCatalogEntry | null }) {
Model Profile {props.catalog?.releaseDate ? formatCatalogDate(props.catalog.releaseDate) : "Listed"} -

No OpenCode usage in the current data window.

+

No OpenCode Go usage in the current data window.

) } @@ -329,10 +329,10 @@ function CatalogDatum(props: { label: string; value: string }) { function ModelOverview(props: { data: StatsModelData | null }) { return (
- + } + fallback={} > {(data) => (
@@ -367,7 +367,7 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) { return (
- + item.tokens > 0)} fallback={} @@ -461,7 +461,7 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) { function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) { return (
- + - + 0} - fallback={} + fallback={} >
@@ -684,7 +684,7 @@ function GeoCountryList(props: { function ModelPeersSection(props: { data: StatsModelData | null }) { return (
- + } diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index ffc1b8bf54a..727ac69869e 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -23,7 +23,6 @@ export type SessionCostEntry = { model: string; cost: number; tokens: number } export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number } export type ModelUsagePoint = { date: string; tokens: number; sessions: number; cost: number } export type ModelMixEntry = { label: string; tokens: number; share: number } -export type ModelProductEntry = { product: string; tokens: number; sessions: number; share: number } export type ModelPeerEntry = { model: string provider: string @@ -63,7 +62,6 @@ export type StatsModelData = { } usage: ModelUsagePoint[] tokenMix: ModelMixEntry[] - productMix: ModelProductEntry[] country: Record peers: ModelPeerEntry[] } @@ -286,7 +284,6 @@ function buildStatsModelData( }, usage: buildModelUsage(currentRows, window, "2M"), tokenMix: buildModelTokenMix(current), - productMix: buildModelProductMix(modelScopedRows, window, current), country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))), peers: buildModelPeers(peers, peerRank, totalTokens), } @@ -523,26 +520,6 @@ function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] { return items.map((item) => ({ ...item, share: round((item.tokens / total) * 100, 1) })) } -function buildModelProductMix( - rows: StatMetricRow[], - window: DateWindow, - fallback: ModelAggregate, -): ModelProductEntry[] { - const products = ["Go", "Zen", "Enterprise"] as const - const items = products.flatMap((product) => { - const aggregate = combineRowsForModel( - fallback.model, - rows.filter((row) => row.tier === product && row.periodStart >= window.start && row.periodStart < window.end), - ) - if (aggregate.totalTokens === 0) return [] - return [{ product, tokens: aggregate.totalTokens, sessions: aggregate.sessions }] - }) - const total = items.reduce((sum, item) => sum + item.tokens, 0) - if (total > 0) return items.map((item) => ({ ...item, share: round((item.tokens / total) * 100, 1) })) - if (fallback.totalTokens === 0) return [] - return [{ product: "All Users", tokens: fallback.totalTokens, sessions: fallback.sessions, share: 100 }] -} - function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] { const start = Math.max(0, Math.min(rank - 4, Math.max(peers.length - 7, 0))) return peers.slice(start, start + 7).map((item, index) => ({ From 417ad240c76fdd26f83c8d6dfdccf2382518d234 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 17 Jun 2026 09:04:36 +0000 Subject: [PATCH 011/335] chore: generate --- packages/stats/app/src/routes/[lab]/[model].tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 746d81ec2f5..cd7e43dfc9a 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -332,7 +332,9 @@ function ModelOverview(props: { data: StatsModelData | null }) { } + fallback={ + + } > {(data) => (
@@ -524,7 +526,9 @@ function ModelGeoBreakdownSection(props: { data: Record 0} - fallback={} + fallback={ + + } >
From 5c9e4ff21b547dfbca30cedf4a3c2c037794e87d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:37:48 +0200 Subject: [PATCH 012/335] feat(app): add v2 home tab toggle (#32191) --- packages/app/src/components/titlebar.tsx | 77 ++++++++----- packages/app/src/context/command.tsx | 107 ++++++++++-------- packages/app/src/context/tabs.tsx | 68 ++++++++++- packages/app/src/desktop-menu.ts | 2 +- packages/app/src/pages/layout.tsx | 2 +- .../ui/src/v2/components/icon-button-v2.css | 1 + 6 files changed, 169 insertions(+), 88 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e813f9f10b0..82771432041 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -7,12 +7,11 @@ import { Match, onMount, Show, - startTransition, Switch, untrack, } from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -20,6 +19,8 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" import { usePlatform } from "@/context/platform" @@ -253,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { const serverSync = useServerSync() const navigate = useNavigate() - const homeMatch = useMatch(() => "/") const layout = useLayout() const newSessionHref = () => { @@ -268,17 +268,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs - const navigateTab = (tab: Tab) => { - const href = tabHref(tab) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) - } const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -309,7 +298,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() if (!tabs.ready()) return const tab = currentTab() - if (tab) return + if (tab) { + tabs.remember(tab) + return + } if (route.type === "session") { const sync = serverSync().createDirSyncContext(route.dir) @@ -332,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }) const openNewTab = () => navigate(newSessionHref()) + const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) + + command.register("titlebar-home", () => [ + { + id: "home.toggle", + title: language.t("home.title"), + category: language.t("command.category.view"), + keybind: "mod+b", + hidden: true, + onSelect: toggleHome, + }, + ]) command.register("tabs", () => { const current = currentTab() @@ -369,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === -1) index = tabsStore.length - 1 const next = tabsStore[index] - if (next) navigateTab(next) + if (next) tabs.select(next) }, }, { @@ -386,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { if (index === tabsStore.length) index = 0 const next = tabsStore[index] - if (next) navigateTab(next) + if (next) tabs.select(next) }, }, ...Array.from({ length: 9 }, (_, i) => { @@ -401,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { hidden: true, onSelect: () => { const tab = tabsStore[index] - if (tab) navigateTab(tab) + if (tab) tabs.select(tab) }, } }), @@ -427,15 +431,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { - } - state={!!homeMatch() ? "pressed" : undefined} - /> + + {language.t("home.title")} + + + } + class="shrink-0" + > + } + state={layout.route().type === "home" ? "pressed" : undefined} + onClick={toggleHome} + aria-label={language.t("home.title")} + aria-pressed={layout.route().type === "home"} + /> +
{ - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} onClose={() => tabsStoreActions.removeTab(i())} @@ -488,7 +505,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { directory={decode64(tab.dirBase64)!} sessionId={tab.sessionId} onNavigate={() => { - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} @@ -518,7 +535,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { title={language.t("command.session.new")} onClose={() => { const tab = tabsStore.at(-1) - if (tab) navigateTab(tab) + if (tab) tabs.select(tab) else navigate("/") }} /> diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e979ad6a059..16483706b8e 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean return false } -export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { - if (!config || config === "none") return "" - - const keybinds = parseKeybind(config) - if (keybinds.length === 0) return "" - - const kb = keybinds[0] +function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { const parts: string[] = [] if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) @@ -184,40 +178,52 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t)) - if (kb.key) { - const keys: Record = { - arrowup: "↑", - arrowdown: "↓", - arrowleft: "←", - arrowright: "→", - comma: ",", - plus: "+", - } - const named: Record = { - backspace: "common.key.backspace", - delete: "common.key.delete", - end: "common.key.end", - enter: "common.key.enter", - esc: "common.key.esc", - escape: "common.key.esc", - home: "common.key.home", - insert: "common.key.insert", - pagedown: "common.key.pageDown", - pageup: "common.key.pageUp", - space: "common.key.space", - tab: "common.key.tab", - } - const key = kb.key.toLowerCase() - const displayKey = - keys[key] ?? - (named[key] - ? keyText(named[key], t) - : key.length === 1 - ? key.toUpperCase() - : key.charAt(0).toUpperCase() + key.slice(1)) - parts.push(displayKey) - } + if (!kb.key) return parts + const keys: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + comma: ",", + plus: "+", + } + const named: Record = { + backspace: "common.key.backspace", + delete: "common.key.delete", + end: "common.key.end", + enter: "common.key.enter", + esc: "common.key.esc", + escape: "common.key.esc", + home: "common.key.home", + insert: "common.key.insert", + pagedown: "common.key.pageDown", + pageup: "common.key.pageUp", + space: "common.key.space", + tab: "common.key.tab", + } + const key = kb.key.toLowerCase() + const displayKey = + keys[key] ?? + (named[key] + ? keyText(named[key], t) + : key.length === 1 + ? key.toUpperCase() + : key.charAt(0).toUpperCase() + key.slice(1)) + parts.push(displayKey) + + return parts +} + +export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] { + if (!config || config === "none") return [] + const keybind = parseKeybind(config)[0] + return keybind ? displayKeybindParts(keybind, t) : [] +} + +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { + const parts = formatKeybindParts(config, t) + if (parts.length === 0) return "" return IS_MAC ? parts.join("") : parts.join("+") } @@ -402,25 +408,26 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) } + const keybindConfig = (id: string) => { + if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + const base = actionId(id) + return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind) + } + return { register, trigger(id: string, source?: CommandSource) { run(id, source) }, keybind(id: string) { - if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t) - } - - const base = actionId(id) - const option = options().find((x) => actionId(x.id) === base) - if (option?.keybind) return formatKeybind(option.keybind, language.t) - - const meta = catalog[base] - const config = bind(base, meta?.keybind) + const config = keybindConfig(id) if (!config) return "" return formatKeybind(config, language.t) }, + keybindParts(id: string) { + const config = keybindConfig(id) + return config ? formatKeybindParts(config, language.t) : [] + }, show: showPalette, keybinds(enabled: boolean) { setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 7f6274cf992..393875ff091 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -27,6 +27,10 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab +type RecentTab = { + key?: string +} + export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => @@ -62,26 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, createStore([]), ) + const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore({})) const params = useParams() const navigate = useNavigate() const location = useLocation() const closing = new Set() + let recentWrite = 0 + let recentValue: string | undefined + + const recentKey = () => (recentWrite ? recentValue : recent.key) + + const setRecentKey = (key: string | undefined) => { + const write = ++recentWrite + recentValue = key + if (recentReady()) { + setRecent("key", key) + return + } + void recentReady.promise?.then(() => { + if (write === recentWrite) setRecent("key", key) + }) + } const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } createEffect(() => { - if (!ready()) return + if (!ready() || !recentReady()) return const servers = new Set(server.list.map(ServerConnection.key)) - if (store.every((tab) => servers.has(tab.server))) return - setStore((tabs) => tabs.filter((tab) => servers.has(tab.server))) + const next = store.filter((tab) => servers.has(tab.server)) + if (next.length !== store.length) setStore(() => next) + if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) const navigateTab = (tab: Tab) => { const href = tabHref(tab) + setRecentKey(tabKey(tab)) if (tab.server === server.key) { navigate(href) return @@ -129,14 +152,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID + const next = { type: "session" as const, ...session } startTransition(() => { setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index !== -1) tabs[index] = { type: "session", ...session } + if (index !== -1) tabs[index] = next }), ) - if (active) navigateTab({ type: "session", ...session }) + if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) + if (active) navigateTab(next) }) removeDraftPersisted(draftID) }, @@ -153,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ tabs.splice(index, 1) }), ) + if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) @@ -160,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, removeServer(key: ServerConnection.Key) { const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) + const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { + const removed = store + .filter( + (tab) => + tab.type === "session" && + tab.server === server.key && + atob(tab.dirBase64) === input.directory && + input.sessionIDs.includes(tab.sessionId), + ) + .map(tabKey) void startTransition(() => { setStore( produce((tabs) => { @@ -207,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ else navigate("/") }), ) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) }, + select: navigateTab, + remember(tab: Tab) { + const key = tabKey(tab) + if (recentKey() !== key) setRecentKey(key) + }, + toggleHome(input: { home: boolean; current?: Tab }) { + if (input.home) { + const tab = store.find((tab) => tabKey(tab) === recentKey()) + if (tab) navigateTab(tab) + return + } + if (input.current) { + setRecentKey(tabKey(input.current)) + navigate("/") + return + } + navigate("/") + }, } - return { ...actions, store, ready } + return { ...actions, store, ready, recentReady } }, }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 677738fde90..d3902cd2b24 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ id: "view", label: "View", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "separator" }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index be8d7c07860..4177d4919c1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -996,7 +996,7 @@ export default function Layout(props: ParentProps) { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), - keybind: "mod+b", + keybind: newDesign() ? undefined : "mod+b", onSelect: () => layout.sidebar.toggle(), }, { diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index e75e4c7aded..c4a3df9115e 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -137,6 +137,7 @@ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { background-color: var(--v2-overlay-simple-overlay-pressed); + color: var(--v2-icon-icon-base); } [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) { From 85a79292e699da616321d60dc3f62b9199c2beca Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 17 Jun 2026 05:37:59 -0500 Subject: [PATCH 013/335] fix(stats): map lab aliases --- .../stats/app/src/routes/model-catalog.ts | 19 ++++++++++++++++--- packages/stats/core/src/domain/home.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index 1eb1cda0945..5733f4a77a5 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -63,17 +63,17 @@ export const getModelCatalog = query(async () => { }, "getModelCatalog") export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) { - const normalizedId = lab ? `${catalogSlug(lab)}/${catalogSlug(model)}` : model.trim().toLowerCase() + const normalizedId = lab ? `${catalogLabSlug(lab)}/${catalogSlug(model)}` : model.trim().toLowerCase() const leaf = catalogSlug(model) return ( catalog.models.find((entry) => entry.id.toLowerCase() === normalizedId) ?? - catalog.models.find((entry) => (lab ? entry.lab === catalogSlug(lab) : true) && entry.slug === leaf) ?? + catalog.models.find((entry) => (lab ? entry.lab === catalogLabSlug(lab) : true) && entry.slug === leaf) ?? catalog.models.find((entry) => entry.slug === leaf) ) } export function findModelCatalogLab(catalog: ModelCatalog, lab: string) { - const id = catalogSlug(lab) + const id = catalogLabSlug(lab) return catalog.labs.find((entry) => entry.id === id) } @@ -95,6 +95,8 @@ export function formatCatalogLabName(lab: string) { xai: "xAI", xiaomi: "Xiaomi", zai: "Z.ai", + qwen: "Qwen", + zhipu: "Zhipu", zhipuai: "Zhipu", } return known[catalogSlug(lab)] ?? lab.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) @@ -273,6 +275,17 @@ function catalogIdKey(value: string) { return value.split("/").map(catalogSlug).join("/") } +function catalogLabSlug(value: string) { + const slug = catalogSlug(value) + const aliases: Record = { + moonshot: "moonshotai", + qwen: "alibaba", + zhipu: "zhipuai", + zai: "zhipuai", + } + return aliases[slug] ?? slug +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 727ac69869e..3023ae32365 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -823,15 +823,14 @@ function resolveModelProvider(model: string, rows: StatMetricRow[], providerPara } function providerMatches(provider: string, providerParam: string) { - return modelSlug(provider) === modelSlug(providerParam) + return providerSlug(provider) === providerSlug(providerParam) } function resolveProviderName(providerParam: string, rows: StatMetricRow[]) { const input = providerParam.trim() if (!input) return undefined - const inputSlug = modelSlug(input) return aggregateByModel(rows) - .filter((item) => modelSlug(item.provider) === inputSlug) + .filter((item) => providerMatches(item.provider, input)) .toSorted((a, b) => b.totalTokens - a.totalTokens || a.provider.localeCompare(b.provider))[0]?.provider } @@ -848,6 +847,17 @@ function modelKey(provider: string, model: string) { return `${provider}\u0000${model}` } +function providerSlug(value: string) { + const slug = modelSlug(value) + const aliases: Record = { + alibaba: "qwen", + moonshotai: "moonshot", + qwen: "qwen", + zhipuai: "zhipu", + } + return aliases[slug] ?? slug +} + function costPerMillion(costMicrocents: number, tokens: number) { if (tokens <= 0 || costMicrocents <= 0) return 0 return round((microcentsToDollars(costMicrocents) / tokens) * TOKEN_SCALE, 2) From 10b6672be1dae17267e85bffb1c14992a60c19c8 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 17 Jun 2026 13:04:33 +0200 Subject: [PATCH 014/335] go: glm 5.2 --- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/uk.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 4 ++-- .../src/routes/workspace/[id]/go/lite-section.tsx | 2 +- packages/web/src/content/docs/ar/go.mdx | 12 ++++++------ packages/web/src/content/docs/bs/go.mdx | 12 ++++++------ packages/web/src/content/docs/da/go.mdx | 12 ++++++------ packages/web/src/content/docs/de/go.mdx | 12 ++++++------ packages/web/src/content/docs/es/go.mdx | 12 ++++++------ packages/web/src/content/docs/fr/go.mdx | 12 ++++++------ packages/web/src/content/docs/go.mdx | 12 ++++++------ packages/web/src/content/docs/it/go.mdx | 12 ++++++------ packages/web/src/content/docs/ja/go.mdx | 12 ++++++------ packages/web/src/content/docs/ko/go.mdx | 12 ++++++------ packages/web/src/content/docs/nb/go.mdx | 12 ++++++------ packages/web/src/content/docs/pl/go.mdx | 12 ++++++------ packages/web/src/content/docs/pt-br/go.mdx | 12 ++++++------ packages/web/src/content/docs/ru/go.mdx | 12 ++++++------ packages/web/src/content/docs/th/go.mdx | 12 ++++++------ packages/web/src/content/docs/tr/go.mdx | 12 ++++++------ packages/web/src/content/docs/zh-cn/go.mdx | 12 ++++++------ packages/web/src/content/docs/zh-tw/go.mdx | 12 ++++++------ 38 files changed, 183 insertions(+), 183 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 182cee4c1a5..cb2ad816c9b 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -299,7 +299,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 296c6801fc6..5c9410556f9 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 23c0f6d2caa..4bd3f46a524 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index a5b619a98af..7619ec2e78c 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 5e7896ce425..97a8ede15be 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -347,7 +347,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 9d346092e4b..4006b33576b 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index fab13d68f61..b722de9ff70 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.banner.text": "MiniMax M3 bénéficie de limites d’utilisation 3x supérieures pour une durée limitée", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -354,7 +354,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 918f552b140..e93f40bf4dd 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.banner.text": "MiniMax M3 offre limiti di utilizzo 3x superiori per un periodo limitato", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 0b665eeda5b..f00b749a33c 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.banner.text": "MiniMax M3の利用上限が期間限定で3倍に", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index b92f7287b5a..42b980e26e3 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.banner.text": "MiniMax M3 사용 한도가 한시적으로 3배 확대됩니다", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 7334f225624..4c5a16babd7 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.banner.text": "MiniMax M3 får 3x bruksgrense i en begrenset periode", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 5f4a6a93737..0cb643a2c12 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.banner.text": "MiniMax M3 oferuje 3x wyższe limity użycia przez ograniczony czas", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 14fa38a43a7..71d8967995e 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.banner.text": "MiniMax M3 получает 3x лимиты использования на ограниченное время", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -306,7 +306,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -332,7 +332,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -356,7 +356,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 852487a5a2a..506de20b61d 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.banner.text": "MiniMax M3 เพิ่มโควตาการใช้งานเป็น 3 เท่าในช่วงเวลาจำกัด", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -299,7 +299,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -324,7 +324,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -347,7 +347,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 08a7af72e1a..288e7329dad 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.banner.text": "MiniMax M3 sınırlı bir süre için 3x kullanım limiti sunuyor", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 23bccb60b1b..6d72beb2e11 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", "go.banner.text": "MiniMax M3 отримує 3x ліміти використання протягом обмеженого часу", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3e381df4aa6..a353d52faca 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -242,7 +242,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.banner.text": "MiniMax M3 限时享受 3 倍使用额度", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -290,7 +290,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 GLM-5.2, GLM-5.1, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -312,7 +312,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -334,7 +334,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.2, GLM-5.1, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 689e2a887e3..e378083c085 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -242,7 +242,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.banner.text": "MiniMax M3 限時享有 3 倍使用額度", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -290,7 +290,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -312,7 +312,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -334,7 +334,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 42da8d22040..b0409b174c0 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -23,8 +23,8 @@ const checkLoggedIn = query(async () => { }, "checkLoggedIn.get") const models = [ + { name: "GLM-5.2", provider: "DeepInfra, Fireworks AI, Z.ai" }, { name: "GLM-5.1", provider: "DeepInfra, Fireworks AI, Z.ai" }, - { name: "GLM-5", provider: "DeepInfra, Fireworks AI, Z.ai" }, { name: "Kimi K2.7 Code", provider: "Moonshot AI" }, { name: "Kimi K2.6", provider: "Moonshot AI" }, { name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" }, @@ -61,7 +61,7 @@ function LimitsGraph(props: { href: string }) { const free = 200 const graph = [ - { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, + { id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" }, { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" }, diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index dc8354d8720..7461fa64ce0 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -259,8 +259,8 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {