From 0f6bc8ae71d18645212cb954bd210f047b5416d8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:24:55 -0500 Subject: [PATCH 001/357] tweak: adjust way skills are presented to agent to increase likelyhood of skill invocations. (#17053) --- packages/opencode/src/session/prompt.ts | 7 ++++++- packages/opencode/src/session/system.ts | 16 ++++++++++++++ packages/opencode/src/skill/skill.ts | 23 ++++++++++++++++++++ packages/opencode/src/tool/skill.ts | 28 +++++-------------------- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 655afd2b14d..54adf1104a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -650,7 +650,12 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) // Build system prompt, adding structured output instruction if needed - const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())] + const skills = await SystemPrompt.skills(agent) + const system = [ + ...(await SystemPrompt.environment(model)), + ...(skills ? [skills] : []), + ...(await InstructionPrompt.system()), + ] const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a61dd8cba55..0f0f6b51b3c 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -10,6 +10,9 @@ import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_CODEX from "./prompt/codex_header.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" +import type { Agent } from "@/agent/agent" +import { PermissionNext } from "@/permission/next" +import { Skill } from "@/skill" export namespace SystemPrompt { export function instructions() { @@ -34,6 +37,7 @@ export namespace SystemPrompt { `Here is some useful information about the environment you are running in:`, ``, ` Working directory: ${Instance.directory}`, + ` Workspace root folder: ${Instance.worktree}`, ` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, @@ -51,4 +55,16 @@ export namespace SystemPrompt { ].join("\n"), ] } + + export async function skills(agent: Agent.Info) { + if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return + + const list = await Skill.available(agent) + + return [ + "Skills provide specialized instructions and workflows for specific tasks.", + "Use the skill tool to load a skill when a task matches its description.", + list.length === 0 ? "No skills are currently available." : "\n" + Skill.fmt(list), + ].join("\n") + } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index c474c94dd74..09cc787c802 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -13,6 +13,9 @@ import { Bus } from "@/bus" import { Session } from "@/session" import { Discovery } from "./discovery" import { Glob } from "../util/glob" +import { pathToFileURL } from "url" +import type { Agent } from "@/agent/agent" +import { PermissionNext } from "@/permission/next" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -186,4 +189,24 @@ export namespace Skill { export async function dirs() { return state().then((x) => x.dirs) } + + export async function available(agent?: Agent.Info) { + const list = await all() + if (!agent) return list + return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + } + + export function fmt(list: Info[]) { + return [ + "", + ...list.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + ` `, + ]), + "", + ].join("\n") + } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8fcfb592dee..6d2a48b0ed2 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,24 +3,14 @@ import { pathToFileURL } from "url" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { PermissionNext } from "../permission/next" import { Ripgrep } from "../file/ripgrep" import { iife } from "@/util/iife" export const SkillTool = Tool.define("skill", async (ctx) => { - const skills = await Skill.all() - - // Filter skills by agent permissions if agent provided - const agent = ctx?.agent - const accessibleSkills = agent - ? skills.filter((skill) => { - const rule = PermissionNext.evaluate("skill", skill.name, agent.permission) - return rule.action !== "deny" - }) - : skills + const list = await Skill.available(ctx?.agent) const description = - accessibleSkills.length === 0 + list.length === 0 ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." : [ "Load a specialized skill that provides domain-specific instructions and workflows.", @@ -34,18 +24,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { "The following skills provide specialized sets of instructions for particular tasks", "Invoke this tool to load a skill when a task matches one of the available skills listed below:", "", - "", - ...accessibleSkills.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - ` `, - ]), - "", + Skill.fmt(list), ].join("\n") - const examples = accessibleSkills + const examples = list .map((skill) => `'${skill.name}'`) .slice(0, 3) .join(", ") @@ -62,7 +44,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { const skill = await Skill.get(params.name) if (!skill) { - const available = await Skill.all().then((x) => Object.keys(x).join(", ")) + const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } From 9c585bb58ba98826cd5f7bf596cb65f411d378a4 Mon Sep 17 00:00:00 2001 From: xinxin <45682184+andyWang1688@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:35:16 +0800 Subject: [PATCH 002/357] docs(providers): clarify npm choice for chat vs responses APIs (#16974) Co-authored-by: wangxinxin --- packages/web/src/content/docs/providers.mdx | 4 ++-- packages/web/src/content/docs/zh-cn/providers.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 34e3626499c..7f993205193 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1890,7 +1890,7 @@ You can use any OpenAI-compatible provider with opencode. Most modern AI provide ``` Here are the configuration options: - - **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers + - **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers (for `/v1/chat/completions`). If your provider/model uses `/v1/responses`, use `@ai-sdk/openai`. - **name**: Display name in UI. - **models**: Available models. - **options.baseURL**: API endpoint URL. @@ -1957,5 +1957,5 @@ If you are having trouble with configuring a provider, check the following: 2. For custom providers, check the opencode config and: - Make sure the provider ID used in the `/connect` command matches the ID in your opencode config. - - The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible`. + - The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible` (for `/v1/chat/completions`); if a model uses `/v1/responses`, use `@ai-sdk/openai`. For mixed setups under one provider, you can override per model via `provider.npm`. - Check correct API endpoint is used in the `options.baseURL` field. diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 9c1616876d7..571b730242c 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -1845,7 +1845,7 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G ``` 以下是配置选项说明: - - **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible` + - **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible`(适用于 `/v1/chat/completions`)。如果你的提供商/模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。 - **name**:在 UI 中显示的名称。 - **models**:可用模型。 - **options.baseURL**:API 端点 URL。 @@ -1911,5 +1911,5 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G 2. 对于自定义提供商,请检查 OpenCode 配置并确认: - `/connect` 命令中使用的提供商 ID 与 OpenCode 配置中的 ID 一致。 - - 使用了正确的 npm 包。例如,Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`。 + - 使用了正确的 npm 包。例如,Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`(`/v1/chat/completions`);如果模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。同一 provider 混用时,可在模型下设置 `provider.npm` 覆盖默认值。 - `options.baseURL` 字段中的 API 端点地址正确。 From bcc0d198678f9e88c1868bda2e7f6e54768117fe Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:24:51 -0500 Subject: [PATCH 003/357] chore(app): simplify review pane (#17066) --- .../app/e2e/session/session-review.spec.ts | 186 +++++++++ packages/app/src/pages/session.tsx | 141 +++---- packages/ui/src/components/file.tsx | 147 ++----- .../components/session-review-search.test.ts | 39 -- .../src/components/session-review-search.ts | 59 --- packages/ui/src/components/session-review.tsx | 376 +----------------- packages/ui/src/pierre/file-find.ts | 180 ++------- 7 files changed, 319 insertions(+), 809 deletions(-) create mode 100644 packages/app/e2e/session/session-review.spec.ts delete mode 100644 packages/ui/src/components/session-review-search.test.ts delete mode 100644 packages/ui/src/components/session-review-search.ts diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts new file mode 100644 index 00000000000..4198c733c0d --- /dev/null +++ b/packages/app/e2e/session/session-review.spec.ts @@ -0,0 +1,186 @@ +import { waitSessionIdle, withSession } from "../actions" +import { test, expect } from "../fixtures" +import { createSdk } from "../utils" + +const count = 14 + +function body(mark: string) { + return [ + `title ${mark}`, + `mark ${mark}`, + ...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`), + ] +} + +function files(tag: string) { + return Array.from({ length: count }, (_, i) => { + const id = String(i).padStart(2, "0") + return { + file: `review-scroll-${id}.txt`, + mark: `${tag}-${id}`, + } + }) +} + +function seed(list: ReturnType) { + const out = ["*** Begin Patch"] + + for (const item of list) { + out.push(`*** Add File: ${item.file}`) + for (const line of body(item.mark)) out.push(`+${line}`) + } + + out.push("*** End Patch") + return out.join("\n") +} + +function edit(file: string, prev: string, next: string) { + return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join( + "\n", + ) +} + +async function patch(sdk: ReturnType, sessionID: string, patchText: string) { + await sdk.session.promptAsync({ + sessionID, + agent: "build", + system: [ + "You are seeding deterministic e2e UI state.", + "Your only valid response is one apply_patch tool call.", + `Use this JSON input: ${JSON.stringify({ patchText })}`, + "Do not call any other tools.", + "Do not output plain text.", + ].join("\n"), + parts: [{ type: "text", text: "Apply the provided patch exactly once." }], + }) + + await waitSessionIdle(sdk, sessionID, 120_000) +} + +async function show(page: Parameters[0]["page"]) { + const btn = page.getByRole("button", { name: "Toggle review" }).first() + await expect(btn).toBeVisible() + if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click() + await expect(btn).toHaveAttribute("aria-expanded", "true") +} + +async function expand(page: Parameters[0]["page"]) { + const close = page.getByRole("button", { name: /^Collapse all$/i }).first() + const open = await close + .isVisible() + .then((value) => value) + .catch(() => false) + + const btn = page.getByRole("button", { name: /^Expand all$/i }).first() + if (open) { + await close.click() + await expect(btn).toBeVisible() + } + + await expect(btn).toBeVisible() + await btn.click() + await expect(close).toBeVisible() +} + +async function waitMark(page: Parameters[0]["page"], file: string, mark: string) { + await page.waitForFunction( + ({ file, mark }) => { + const head = Array.from(document.querySelectorAll("h3")).find( + (node) => node instanceof HTMLElement && node.textContent?.includes(file), + ) + if (!(head instanceof HTMLElement)) return false + + return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => { + if (!(host instanceof HTMLElement)) return false + const root = host.shadowRoot + return root?.textContent?.includes(`mark ${mark}`) ?? false + }) + }, + { file, mark }, + { timeout: 60_000 }, + ) +} + +test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { + test.setTimeout(180_000) + + const tag = `review-${Date.now()}` + const list = files(tag) + const hit = list[list.length - 2]! + const next = `${tag}-live` + + await page.setViewportSize({ width: 1600, height: 1000 }) + + await withProject(async (project) => { + const sdk = createSdk(project.directory) + + await withSession(sdk, `e2e review ${tag}`, async (session) => { + await patch(sdk, session.id, seed(list)) + + await expect + .poll( + async () => { + const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data) + return info?.summary?.files ?? 0 + }, + { timeout: 60_000 }, + ) + .toBe(list.length) + + await expect + .poll( + async () => { + const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(list.length) + + await project.gotoSession(session.id) + await show(page) + + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() + + const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() + await expect(view).toBeVisible() + const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) + await expect(heads).toHaveCount(list.length, { + timeout: 60_000, + }) + + await expand(page) + await waitMark(page, hit.file, hit.mark) + + const row = page + .getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }) + .first() + await expect(row).toBeVisible() + await row.evaluate((el) => el.scrollIntoView({ block: "center" })) + + await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200) + const prev = await view.evaluate((el) => el.scrollTop) + + await patch(sdk, session.id, edit(hit.file, hit.mark, next)) + + await expect + .poll( + async () => { + const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const item = diff.find((item) => item.file === hit.file) + return typeof item?.after === "string" ? item.after : "" + }, + { timeout: 60_000 }, + ) + .toContain(`mark ${next}`) + + await waitMark(page, hit.file, next) + + await expect + .poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 }) + .toBeLessThanOrEqual(16) + }) + }) +}) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 79c8d42f558..a5c7bf90b32 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -862,6 +862,36 @@ export default function Page() { ) + const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { + if (store.changes === "turn") return emptyTurn() + + if (hasReview() && !diffsReady()) { + return
{language.t("session.review.loadingChanges")}
+ } + + if (reviewEmptyKey() === "session.review.noVcs") { + return ( +
+
+
Create a Git repository
+
+ Track, review, and undo changes in this project +
+
+ +
+ ) + } + + return ( +
+
{language.t(reviewEmptyKey())}
+
+ ) + } + const reviewContent = (input: { diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void @@ -870,98 +900,25 @@ export default function Page() { emptyClass: string }) => ( - - - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - - {language.t("session.review.loadingChanges")}} - > - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - - - -
-
Create a Git repository
-
- Track, review, and undo changes in this project -
-
- - - ) : ( -
-
{language.t(reviewEmptyKey())}
-
- ) - } - diffs={reviewDiffs} - view={view} - diffStyle={input.diffStyle} - onDiffStyleChange={input.onDiffStyleChange} - onScrollRef={(el) => setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> -
-
+ setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + />
) diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index f42fbb24d22..15915dd52d4 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -1,10 +1,8 @@ import { sampledChecksum } from "@opencode-ai/util/encode" import { DEFAULT_VIRTUAL_FILE_METRICS, - type ExpansionDirections, type DiffLineAnnotation, type FileContents, - type FileDiffMetadata, File as PierreFile, type FileDiffOptions, FileDiff, @@ -22,7 +20,7 @@ import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMo import { createDefaultOptions, styleVariables } from "../pierre" import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines" import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection" -import { createFileFind, type FileFindReveal } from "../pierre/file-find" +import { createFileFind } from "../pierre/file-find" import { applyViewerScheme, clearReadyWatcher, @@ -65,21 +63,11 @@ type SharedProps = { search?: FileSearchControl } -export type FileSearchReveal = FileFindReveal - export type FileSearchHandle = { focus: () => void - setQuery: (value: string) => void - clear: () => void - reveal: (hit: FileSearchReveal) => boolean - expand: (hit: FileSearchReveal) => boolean - refresh: () => void } export type FileSearchControl = { - shortcuts?: "global" | "disabled" - showBar?: boolean - disableVirtualization?: boolean register: (handle: FileSearchHandle | null) => void } @@ -121,40 +109,6 @@ const sharedKeys = [ const textKeys = ["file", ...sharedKeys] as const const diffKeys = ["before", "after", ...sharedKeys] as const -function expansionForHit(diff: FileDiffMetadata, hit: FileSearchReveal) { - if (diff.isPartial || diff.hunks.length === 0) return - - const side = - hit.side === "deletions" - ? { - start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionStart, - count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionCount, - } - : { - start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionStart, - count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionCount, - } - - for (let i = 0; i < diff.hunks.length; i++) { - const hunk = diff.hunks[i] - const start = side.start(hunk) - if (hit.line < start) { - return { - index: i, - direction: i === 0 ? "down" : "both", - } satisfies { index: number; direction: ExpansionDirections } - } - - const end = start + Math.max(side.count(hunk) - 1, -1) - if (hit.line <= end) return - } - - return { - index: diff.hunks.length, - direction: "up", - } satisfies { index: number; direction: ExpansionDirections } -} - // --------------------------------------------------------------------------- // Shared viewer hook // --------------------------------------------------------------------------- @@ -167,7 +121,6 @@ type MouseHit = { type ViewerConfig = { enableLineSelection: () => boolean - search: () => FileSearchControl | undefined selectedLines: () => SelectedLineRange | null | undefined commentedLines: () => SelectedLineRange[] onLineSelectionEnd: (range: SelectedLineRange | null) => void @@ -207,7 +160,6 @@ function useFileViewer(config: ViewerConfig) { wrapper: () => wrapper, overlay: () => overlay, getRoot, - shortcuts: config.search()?.shortcuts, }) // -- selection scheduling -- @@ -407,14 +359,10 @@ function useFileViewer(config: ViewerConfig) { type Viewer = ReturnType -type ModeAdapter = Omit< - ViewerConfig, - "enableLineSelection" | "search" | "selectedLines" | "commentedLines" | "onLineSelectionEnd" -> +type ModeAdapter = Omit type ModeConfig = { enableLineSelection: () => boolean - search: () => FileSearchControl | undefined selectedLines: () => SelectedLineRange | null | undefined commentedLines: () => SelectedLineRange[] | undefined onLineSelectionEnd: (range: SelectedLineRange | null) => void @@ -437,7 +385,6 @@ type VirtualStrategy = { function useModeViewer(config: ModeConfig, adapter: ModeAdapter) { return useFileViewer({ enableLineSelection: config.enableLineSelection, - search: config.search, selectedLines: config.selectedLines, commentedLines: () => config.commentedLines() ?? [], onLineSelectionEnd: config.onLineSelectionEnd, @@ -448,32 +395,13 @@ function useModeViewer(config: ModeConfig, adapter: ModeAdapter) { function useSearchHandle(opts: { search: () => FileSearchControl | undefined find: ReturnType - expand?: (hit: FileSearchReveal) => boolean }) { createEffect(() => { const search = opts.search() if (!search) return const handle = { - focus: () => { - opts.find.focus() - }, - setQuery: (value: string) => { - opts.find.activate() - opts.find.setQuery(value, { scroll: false }) - }, - clear: () => { - opts.find.clear() - }, - reveal: (hit: FileSearchReveal) => { - opts.find.activate() - return opts.find.reveal(hit) - }, - expand: (hit: FileSearchReveal) => opts.expand?.(hit) ?? false, - refresh: () => { - opts.find.activate() - opts.find.refresh() - }, + focus: () => opts.find.focus(), } satisfies FileSearchHandle search.register(handle) @@ -563,6 +491,29 @@ function renderViewer(opts: { opts.onReady() } +function preserve(viewer: Viewer) { + const root = scrollParent(viewer.wrapper) + if (!root) return () => {} + + const high = viewer.container.getBoundingClientRect().height + if (!high) return () => {} + + const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top + const prev = viewer.container.style.minHeight + viewer.container.style.minHeight = `${Math.ceil(high)}px` + + let done = false + return () => { + if (done) return + done = true + viewer.container.style.minHeight = prev + + const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top + const delta = next - top + if (delta) root.scrollTop += delta + } +} + function scrollParent(el: HTMLElement): HTMLElement | undefined { let parent = el.parentElement while (parent) { @@ -606,7 +557,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab } } -function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy { +function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy { let shared: NonNullable> | undefined const release = () => { @@ -616,10 +567,6 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, ena return { get: () => { - if (!enabled()) { - release() - return - } if (shared) return shared.virtualizer const container = host() @@ -689,7 +636,6 @@ function diffSelectionSide(node: Node | null) { function ViewerShell(props: { mode: "text" | "diff" viewer: ReturnType - search: FileSearchControl | undefined class: string | undefined classList: ComponentProps<"div">["classList"] | undefined }) { @@ -708,7 +654,7 @@ function ViewerShell(props: { onPointerDown={props.viewer.find.onPointerDown} onFocus={props.viewer.find.onFocus} > - + (props: TextFileProps) { viewer = useModeViewer( { enableLineSelection: () => props.enableLineSelection === true, - search: () => local.search, selectedLines: () => local.selectedLines, commentedLines: () => local.commentedLines, onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range), @@ -941,9 +886,7 @@ function TextViewer(props: TextFileProps) { virtuals.cleanup() }) - return ( - - ) + return } // --------------------------------------------------------------------------- @@ -1029,7 +972,6 @@ function DiffViewer(props: DiffFileProps) { viewer = useModeViewer( { enableLineSelection: () => props.enableLineSelection === true, - search: () => local.search, selectedLines: () => local.selectedLines, commentedLines: () => local.commentedLines, onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range), @@ -1037,10 +979,7 @@ function DiffViewer(props: DiffFileProps) { adapter, ) - const virtuals = createSharedVirtualStrategy( - () => viewer.container, - () => local.search?.disableVirtualization !== true, - ) + const virtuals = createSharedVirtualStrategy(() => viewer.container) const large = createMemo(() => { const before = typeof local.before?.contents === "string" ? local.before.contents : "" @@ -1074,12 +1013,13 @@ function DiffViewer(props: DiffFileProps) { return { ...perf, disableLineNumbers: true } }) - const notify = () => { + const notify = (done?: VoidFunction) => { notifyRendered({ viewer, isReady: (root) => root.querySelector("[data-line]") != null, settleFrames: 1, onReady: () => { + done?.() setSelectedLines(viewer.lastSelection) viewer.find.refresh({ reset: true }) local.onRendered?.() @@ -1090,20 +1030,6 @@ function DiffViewer(props: DiffFileProps) { useSearchHandle({ search: () => local.search, find: viewer.find, - expand: (hit) => { - const active = instance as - | ((FileDiff | VirtualizedFileDiff) & { - fileDiff?: FileDiffMetadata - }) - | undefined - if (!active?.fileDiff) return false - - const next = expansionForHit(active.fileDiff, hit) - if (!next) return false - - active.expandHunk(next.index, next.direction) - return true - }, }) // -- render instance -- @@ -1114,6 +1040,9 @@ function DiffViewer(props: DiffFileProps) { const virtualizer = virtuals.get() const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" + const done = preserve(viewer) + + onCleanup(done) const cacheKey = (contents: string) => { if (!large()) return sampledChecksum(contents, contents.length) @@ -1138,7 +1067,7 @@ function DiffViewer(props: DiffFileProps) { containerWrapper: viewer.container, }) }, - onReady: notify, + onReady: () => notify(done), }) }) @@ -1158,9 +1087,7 @@ function DiffViewer(props: DiffFileProps) { dragEndSide = undefined }) - return ( - - ) + return } // --------------------------------------------------------------------------- diff --git a/packages/ui/src/components/session-review-search.test.ts b/packages/ui/src/components/session-review-search.test.ts deleted file mode 100644 index 060df64071e..00000000000 --- a/packages/ui/src/components/session-review-search.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search" - -describe("session review search", () => { - test("builds hits with line, col, and side", () => { - const hits = buildSessionSearchHits({ - query: "alpha", - files: [ - { - file: "a.txt", - before: "alpha\nbeta alpha", - after: "ALPHA", - }, - ], - }) - - expect(hits).toEqual([ - { file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 }, - { file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 }, - { file: "a.txt", side: "additions", line: 1, col: 1, len: 5 }, - ]) - }) - - test("uses non-overlapping matches", () => { - const hits = buildSessionSearchHits({ - query: "aa", - files: [{ file: "a.txt", after: "aaaa" }], - }) - - expect(hits.map((hit) => hit.col)).toEqual([1, 3]) - }) - - test("wraps next and previous navigation", () => { - expect(stepSessionSearchIndex(5, 0, -1)).toBe(4) - expect(stepSessionSearchIndex(5, 4, 1)).toBe(0) - expect(stepSessionSearchIndex(5, 2, 1)).toBe(3) - expect(stepSessionSearchIndex(0, 0, 1)).toBe(0) - }) -}) diff --git a/packages/ui/src/components/session-review-search.ts b/packages/ui/src/components/session-review-search.ts deleted file mode 100644 index 2cff0adc5a3..00000000000 --- a/packages/ui/src/components/session-review-search.ts +++ /dev/null @@ -1,59 +0,0 @@ -export type SessionSearchHit = { - file: string - side: "additions" | "deletions" - line: number - col: number - len: number -} - -type SessionSearchFile = { - file: string - before?: string - after?: string -} - -function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) { - return args.text.split("\n").flatMap((line, i) => { - if (!line) return [] - - const hay = line.toLowerCase() - let at = hay.indexOf(args.needle) - if (at < 0) return [] - - const out: SessionSearchHit[] = [] - while (at >= 0) { - out.push({ - file: args.file, - side: args.side, - line: i + 1, - col: at + 1, - len: args.needle.length, - }) - at = hay.indexOf(args.needle, at + args.needle.length) - } - - return out - }) -} - -export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) { - const value = args.query.trim().toLowerCase() - if (!value) return [] - - return args.files.flatMap((file) => { - const out: SessionSearchHit[] = [] - if (typeof file.before === "string") { - out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value })) - } - if (typeof file.after === "string") { - out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value })) - } - return out - }) -} - -export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) { - if (total <= 0) return 0 - if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1 - return (current + dir + total) % total -} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 62c70e8647d..49c561e0bdb 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -9,9 +9,6 @@ import { IconButton } from "./icon-button" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" -import { FileSearchBar } from "./file-search" -import type { FileSearchHandle } from "./file" -import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search" import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" @@ -63,6 +60,8 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } +type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult } + export interface SessionReviewProps { title?: JSX.Element empty?: JSX.Element @@ -86,7 +85,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult })[] + diffs: ReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise } @@ -135,15 +134,10 @@ type SessionReviewSelection = { export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined - let searchInput: HTMLInputElement | undefined let focusToken = 0 - let revealToken = 0 - let highlightedFile: string | undefined const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() - const searchHandles = new Map() - const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ open: [], force: {}, @@ -152,18 +146,12 @@ export const SessionReview = (props: SessionReviewProps) => { const [selection, setSelection] = createSignal(null) const [commenting, setCommenting] = createSignal(null) const [opened, setOpened] = createSignal(null) - const [searchOpen, setSearchOpen] = createSignal(false) - const [searchQuery, setSearchQuery] = createSignal("") - const [searchActive, setSearchActive] = createSignal(0) - const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 }) const open = () => props.open ?? store.open - const files = createMemo(() => props.diffs.map((d) => d.file)) - const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const))) + const files = createMemo(() => props.diffs.map((diff) => diff.file)) + const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 - const searchValue = createMemo(() => searchQuery().trim()) - const searchExpanded = createMemo(() => searchValue().length > 0) const handleChange = (open: string[]) => { props.onOpenChange?.(open) @@ -176,266 +164,8 @@ export const SessionReview = (props: SessionReviewProps) => { handleChange(next) } - const clearViewerSearch = () => { - for (const handle of searchHandles.values()) handle.clear() - highlightedFile = undefined - } - const openFileLabel = () => i18n.t("ui.sessionReview.openFile") - const selectionLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return i18n.t("ui.sessionReview.selection.line", { line: start }) - return i18n.t("ui.sessionReview.selection.lines", { start, end }) - } - - const focusSearch = () => { - if (!hasDiffs()) return - setSearchOpen(true) - requestAnimationFrame(() => { - searchInput?.focus() - searchInput?.select() - }) - } - - const closeSearch = () => { - revealToken++ - setSearchOpen(false) - setSearchQuery("") - setSearchActive(0) - clearViewerSearch() - } - - const positionSearchBar = () => { - if (typeof window === "undefined") return - if (!scroll) return - - const rect = scroll.getBoundingClientRect() - const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height")) - const header = Number.isNaN(title) ? 0 : title - setSearchPos({ - top: Math.round(rect.top) + header - 4, - right: Math.round(window.innerWidth - rect.right) + 8, - }) - } - - const searchHits = createMemo(() => - buildSessionSearchHits({ - query: searchQuery(), - files: props.diffs.flatMap((diff) => { - if (mediaKindFromPath(diff.file)) return [] - - return [ - { - file: diff.file, - before: typeof diff.before === "string" ? diff.before : undefined, - after: typeof diff.after === "string" ? diff.after : undefined, - }, - ] - }), - }), - ) - - const waitForViewer = (file: string, token: number) => - new Promise((resolve) => { - let attempt = 0 - - const tick = () => { - if (token !== revealToken) { - resolve(undefined) - return - } - - const handle = searchHandles.get(file) - if (handle && readyFiles.has(file)) { - resolve(handle) - return - } - - if (attempt >= 180) { - resolve(undefined) - return - } - - attempt++ - requestAnimationFrame(tick) - } - - tick() - }) - - const waitForFrames = (count: number, token: number) => - new Promise((resolve) => { - const tick = (left: number) => { - if (token !== revealToken) { - resolve(false) - return - } - - if (left <= 0) { - resolve(true) - return - } - - requestAnimationFrame(() => tick(left - 1)) - } - - tick(count) - }) - - const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => { - const diff = diffs().get(hit.file) - if (!diff) return - - if (!open().includes(hit.file)) { - handleChange([...open(), hit.file]) - } - - if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) { - setStore("force", hit.file, true) - } - - const handle = await waitForViewer(hit.file, token) - if (!handle || token !== revealToken) return - if (searchValue() !== query) return - if (!(await waitForFrames(2, token))) return - - if (highlightedFile && highlightedFile !== hit.file) { - searchHandles.get(highlightedFile)?.clear() - highlightedFile = undefined - } - - anchors.get(hit.file)?.scrollIntoView({ block: "nearest" }) - - let done = false - for (let i = 0; i < 4; i++) { - if (token !== revealToken) return - if (searchValue() !== query) return - - handle.setQuery(query) - if (handle.reveal(hit)) { - done = true - break - } - - const expanded = handle.expand(hit) - handle.refresh() - if (!(await waitForFrames(expanded ? 2 : 1, token))) return - } - - if (!done) return - - if (!(await waitForFrames(1, token))) return - handle.reveal(hit) - - highlightedFile = hit.file - } - - const navigateSearch = (dir: 1 | -1) => { - const total = searchHits().length - if (total <= 0) return - setSearchActive((value) => stepSessionSearchIndex(total, value, dir)) - } - - const inReview = (node: unknown, path?: unknown[]) => { - if (node === searchInput) return true - if (path?.some((item) => item === scroll || item === searchInput)) return true - if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) { - return true - } - if (!(node instanceof Node)) return false - if (searchInput?.contains(node)) return true - if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true - if (!scroll) return false - return scroll.contains(node) - } - - createEffect(() => { - if (typeof window === "undefined") return - - const onKeyDown = (event: KeyboardEvent) => { - const mod = event.metaKey || event.ctrlKey - if (!mod) return - - const key = event.key.toLowerCase() - if (key !== "f" && key !== "g") return - - if (key === "f") { - if (!hasDiffs()) return - event.preventDefault() - event.stopPropagation() - focusSearch() - return - } - - const path = typeof event.composedPath === "function" ? event.composedPath() : undefined - if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return - if (!searchOpen()) return - event.preventDefault() - event.stopPropagation() - navigateSearch(event.shiftKey ? -1 : 1) - } - - window.addEventListener("keydown", onKeyDown, { capture: true }) - onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) - }) - - createEffect(() => { - diffStyle() - searchExpanded() - readyFiles.clear() - }) - - createEffect(() => { - if (!searchOpen()) return - if (!scroll) return - - const root = scroll - - requestAnimationFrame(positionSearchBar) - window.addEventListener("resize", positionSearchBar, { passive: true }) - const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar) - observer?.observe(root) - - onCleanup(() => { - window.removeEventListener("resize", positionSearchBar) - observer?.disconnect() - }) - }) - - createEffect(() => { - const total = searchHits().length - if (total === 0) { - if (searchActive() !== 0) setSearchActive(0) - return - } - - if (searchActive() >= total) setSearchActive(total - 1) - }) - - createEffect(() => { - diffStyle() - const query = searchValue() - const hits = searchHits() - const token = ++revealToken - if (!query || hits.length === 0) { - clearViewerSearch() - return - } - - const hit = hits[Math.min(searchActive(), hits.length - 1)] - if (!hit) return - void revealSearchHit(token, hit, query) - }) - - onCleanup(() => { - revealToken++ - clearViewerSearch() - readyFiles.clear() - searchHandles.clear() - }) - const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => { @@ -499,58 +229,6 @@ export const SessionReview = (props: SessionReviewProps) => { }) }) - const handleReviewKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return - - const mod = event.metaKey || event.ctrlKey - const key = event.key.toLowerCase() - const target = event.target - if (mod && key === "f") { - event.preventDefault() - event.stopPropagation() - focusSearch() - return - } - - if (mod && key === "g") { - if (!searchOpen()) return - event.preventDefault() - event.stopPropagation() - navigateSearch(event.shiftKey ? -1 : 1) - } - } - - const handleSearchInputKeyDown = (event: KeyboardEvent) => { - const mod = event.metaKey || event.ctrlKey - const key = event.key.toLowerCase() - - if (mod && key === "g") { - event.preventDefault() - event.stopPropagation() - navigateSearch(event.shiftKey ? -1 : 1) - return - } - - if (mod && key === "f") { - event.preventDefault() - event.stopPropagation() - focusSearch() - return - } - - if (event.key === "Escape") { - event.preventDefault() - event.stopPropagation() - closeSearch() - return - } - - if (event.key !== "Enter") return - event.preventDefault() - event.stopPropagation() - navigateSearch(event.shiftKey ? -1 : 1) - } - return (
@@ -594,31 +272,10 @@ export const SessionReview = (props: SessionReviewProps) => { props.scrollRef?.(el) }} onScroll={props.onScroll as any} - onKeyDown={handleReviewKeyDown} classList={{ [props.classes?.root ?? ""]: !!props.classes?.root, }} > - - (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)} - count={() => searchHits().length} - setInput={(el) => { - searchInput = el - }} - onInput={(value) => { - setSearchQuery(value) - setSearchActive(0) - }} - onKeyDown={(event) => handleSearchInputKeyDown(event)} - onClose={closeSearch} - onPrev={() => navigateSearch(-1)} - onNext={() => navigateSearch(1)} - /> - -
@@ -627,8 +284,7 @@ export const SessionReview = (props: SessionReviewProps) => { {(file) => { let wrapper: HTMLDivElement | undefined - const diff = createMemo(() => diffs().get(file)) - const item = () => diff()! + const item = createMemo(() => diffs().get(file)!) const expanded = createMemo(() => open().includes(file)) const force = () => !!store.force[file] @@ -720,9 +376,6 @@ export const SessionReview = (props: SessionReviewProps) => { onCleanup(() => { anchors.delete(file) - readyFiles.delete(file) - searchHandles.delete(file) - if (highlightedFile === file) highlightedFile = undefined }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -839,9 +492,7 @@ export const SessionReview = (props: SessionReviewProps) => { mode="diff" preloadedDiff={item().preloaded} diffStyle={diffStyle()} - expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20} onRendered={() => { - readyFiles.add(file) props.onDiffRendered?.() }} enableLineSelection={props.onLineComment != null} @@ -854,21 +505,6 @@ export const SessionReview = (props: SessionReviewProps) => { renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined} selectedLines={selectedLines()} commentedLines={commentedLines()} - search={{ - shortcuts: "disabled", - showBar: false, - disableVirtualization: searchExpanded(), - register: (handle: FileSearchHandle | null) => { - if (!handle) { - searchHandles.delete(file) - readyFiles.delete(file) - if (highlightedFile === file) highlightedFile = undefined - return - } - - searchHandles.set(file, handle) - }, - }} before={{ name: file, contents: typeof item().before === "string" ? item().before : "", diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts index 7d55cfa72b8..ee608152d5e 100644 --- a/packages/ui/src/pierre/file-find.ts +++ b/packages/ui/src/pierre/file-find.ts @@ -8,20 +8,6 @@ export type FindHost = { isOpen: () => boolean } -type FileFindSide = "additions" | "deletions" - -export type FileFindReveal = { - side: FileFindSide - line: number - col: number - len: number -} - -type FileFindHit = FileFindReveal & { - range: Range - alt?: number -} - const hosts = new Set() let target: FindHost | undefined let current: FindHost | undefined @@ -112,7 +98,6 @@ type CreateFileFindOptions = { wrapper: () => HTMLElement | undefined overlay: () => HTMLDivElement | undefined getRoot: () => ShadowRoot | undefined - shortcuts?: "global" | "disabled" } export function createFileFind(opts: CreateFileFindOptions) { @@ -120,7 +105,7 @@ export function createFileFind(opts: CreateFileFindOptions) { let overlayFrame: number | undefined let overlayScroll: HTMLElement[] = [] let mode: "highlights" | "overlay" = "overlay" - let hits: FileFindHit[] = [] + let hits: Range[] = [] const [open, setOpen] = createSignal(false) const [query, setQuery] = createSignal("") @@ -161,7 +146,7 @@ export function createFileFind(opts: CreateFileFindOptions) { const frag = document.createDocumentFragment() for (let i = 0; i < hits.length; i++) { - const range = hits[i].range + const range = hits[i] const active = i === currentIndex for (const rect of Array.from(range.getClientRects())) { if (!rect.width || !rect.height) continue @@ -237,7 +222,7 @@ export function createFileFind(opts: CreateFileFindOptions) { const scan = (root: ShadowRoot, value: string) => { const needle = value.toLowerCase() - const ranges: FileFindHit[] = [] + const ranges: Range[] = [] const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) @@ -250,28 +235,6 @@ export function createFileFind(opts: CreateFileFindOptions) { let at = hay.indexOf(needle) if (at === -1) continue - const row = col.closest("[data-line], [data-alt-line]") - if (!(row instanceof HTMLElement)) continue - - const primary = parseInt(row.dataset.line ?? "", 10) - const alt = parseInt(row.dataset.altLine ?? "", 10) - const line = (() => { - if (!Number.isNaN(primary)) return primary - if (!Number.isNaN(alt)) return alt - })() - if (line === undefined) continue - - const side = (() => { - const code = col.closest("[data-code]") - if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions" - - const row = col.closest("[data-line-type]") - if (!(row instanceof HTMLElement)) return "additions" - const type = row.dataset.lineType - if (type === "change-deletion") return "deletions" - return "additions" - })() as FileFindSide - const nodes: Text[] = [] const ends: number[] = [] const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) @@ -305,14 +268,7 @@ export function createFileFind(opts: CreateFileFindOptions) { const range = document.createRange() range.setStart(start.node, start.offset) range.setEnd(end.node, end.offset) - ranges.push({ - range, - side, - line, - alt: Number.isNaN(alt) ? undefined : alt, - col: at + 1, - len: value.length, - }) + ranges.push(range) at = hay.indexOf(needle, at + value.length) } } @@ -321,17 +277,12 @@ export function createFileFind(opts: CreateFileFindOptions) { } const scrollToRange = (range: Range) => { - const scroll = () => { - const start = range.startContainer - const el = start instanceof Element ? start : start.parentElement - el?.scrollIntoView({ block: "center", inline: "center" }) - } - - scroll() - requestAnimationFrame(scroll) + const start = range.startContainer + const el = start instanceof Element ? start : start.parentElement + el?.scrollIntoView({ block: "center", inline: "center" }) } - const setHighlights = (ranges: FileFindHit[], currentIndex: number) => { + const setHighlights = (ranges: Range[], currentIndex: number) => { const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight if (!api || typeof Highlight !== "function") return false @@ -339,37 +290,14 @@ export function createFileFind(opts: CreateFileFindOptions) { api.delete("opencode-find") api.delete("opencode-find-current") - const active = ranges[currentIndex]?.range + const active = ranges[currentIndex] if (active) api.set("opencode-find-current", new Highlight(active)) - const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range])) + const rest = ranges.filter((_, i) => i !== currentIndex) if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) return true } - const select = (currentIndex: number, scroll: boolean) => { - const active = hits[currentIndex]?.range - if (!active) return false - - setIndex(currentIndex) - - if (mode === "highlights") { - if (!setHighlights(hits, currentIndex)) { - mode = "overlay" - apply({ reset: true, scroll }) - return false - } - if (scroll) scrollToRange(active) - return true - } - - clearHighlightFind() - syncOverlayScroll() - if (scroll) scrollToRange(active) - scheduleOverlay() - return true - } - const apply = (args?: { reset?: boolean; scroll?: boolean }) => { if (!open()) return @@ -393,7 +321,7 @@ export function createFileFind(opts: CreateFileFindOptions) { setCount(total) setIndex(currentIndex) - const active = ranges[currentIndex]?.range + const active = ranges[currentIndex] if (mode === "highlights") { clearOverlay() clearOverlayScroll() @@ -420,23 +348,11 @@ export function createFileFind(opts: CreateFileFindOptions) { if (current === host) current = undefined } - const clear = () => { - setQuery("") - clearFind() - } - - const activate = () => { - if (opts.shortcuts !== "disabled") { - if (current && current !== host) current.close() - current = host - target = host - } - - if (!open()) setOpen(true) - } - const focus = () => { - activate() + if (current && current !== host) current.close() + current = host + target = host + if (!open()) setOpen(true) requestAnimationFrame(() => { apply({ scroll: true }) input?.focus() @@ -450,30 +366,25 @@ export function createFileFind(opts: CreateFileFindOptions) { if (total <= 0) return const currentIndex = (index() + dir + total) % total - select(currentIndex, true) - } + setIndex(currentIndex) - const reveal = (targetHit: FileFindReveal) => { - if (!open()) return false - if (hits.length === 0) return false + const active = hits[currentIndex] + if (!active) return - const exact = hits.findIndex( - (hit) => - hit.side === targetHit.side && - hit.line === targetHit.line && - hit.col === targetHit.col && - hit.len === targetHit.len, - ) - const fallback = hits.findIndex( - (hit) => - (hit.line === targetHit.line || hit.alt === targetHit.line) && - hit.col === targetHit.col && - hit.len === targetHit.len, - ) + if (mode === "highlights") { + if (!setHighlights(hits, currentIndex)) { + mode = "overlay" + apply({ reset: true, scroll: true }) + return + } + scrollToRange(active) + return + } - const nextIndex = exact >= 0 ? exact : fallback - if (nextIndex < 0) return false - return select(nextIndex, true) + clearHighlightFind() + syncOverlayScroll() + scrollToRange(active) + scheduleOverlay() } const host: FindHost = { @@ -486,21 +397,17 @@ export function createFileFind(opts: CreateFileFindOptions) { onMount(() => { mode = supportsHighlights() ? "highlights" : "overlay" - if (opts.shortcuts !== "disabled") { - installShortcuts() - hosts.add(host) - if (!target) target = host - } + installShortcuts() + hosts.add(host) + if (!target) target = host onCleanup(() => { - if (opts.shortcuts !== "disabled") { - hosts.delete(host) - if (current === host) { - current = undefined - clearHighlightFind() - } - if (target === host) target = undefined + hosts.delete(host) + if (current === host) { + current = undefined + clearHighlightFind() } + if (target === host) target = undefined }) }) @@ -541,25 +448,20 @@ export function createFileFind(opts: CreateFileFindOptions) { setInput: (el: HTMLInputElement) => { input = el }, - setQuery: (value: string, args?: { scroll?: boolean }) => { + setQuery: (value: string) => { setQuery(value) setIndex(0) - apply({ reset: true, scroll: args?.scroll ?? true }) + apply({ reset: true, scroll: true }) }, - clear, - activate, focus, close, next, - reveal, refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args), onPointerDown: () => { - if (opts.shortcuts === "disabled") return target = host opts.wrapper()?.focus({ preventScroll: true }) }, onFocus: () => { - if (opts.shortcuts === "disabled") return target = host }, onInputKeyDown: (event: KeyboardEvent) => { From 2aae0d3493ac51aa2fd3929c6db0814ab795b04b Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Wed, 11 Mar 2026 19:10:45 +0100 Subject: [PATCH 004/357] fix(core): read stdout and stderr in PackageRegistry.info before waiting for the process to exit (#16998) --- packages/opencode/src/bun/registry.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index 1fc8531442f..e43e20e6c5a 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -1,5 +1,4 @@ import semver from "semver" -import { text } from "node:stream/consumers" import { Log } from "../util/log" import { Process } from "../util/process" @@ -11,26 +10,21 @@ export namespace PackageRegistry { } export async function info(pkg: string, field: string, cwd?: string): Promise { - const result = Process.spawn([which(), "info", pkg, field], { + const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], { cwd, - stdout: "pipe", - stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1", }, + nothrow: true, }) - const code = await result.exited - const stdout = result.stdout ? await text(result.stdout) : "" - const stderr = result.stderr ? await text(result.stderr) : "" - if (code !== 0) { - log.warn("bun info failed", { pkg, field, code, stderr }) + log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() }) return null } - const value = stdout.trim() + const value = stdout.toString().trim() if (!value) return null return value } From 981c7b9e375b7d9ac57d2d6a3179451139b2b99b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 11 Mar 2026 14:18:58 -0400 Subject: [PATCH 005/357] refactor(account): tighten effect-based account flows (#17072) --- packages/opencode/AGENTS.md | 34 ++ packages/opencode/src/account/account.sql.ts | 18 +- packages/opencode/src/account/index.ts | 8 +- packages/opencode/src/account/repo.ts | 192 ++++----- packages/opencode/src/account/schema.ts | 26 +- packages/opencode/src/account/service.ts | 363 ++++++++---------- packages/opencode/src/cli/cmd/account.ts | 8 +- packages/opencode/test/account/repo.test.ts | 70 ++-- .../opencode/test/account/service.test.ts | 35 +- 9 files changed, 395 insertions(+), 359 deletions(-) diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index dcfc336d652..930297baa9f 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -8,3 +8,37 @@ - **Command**: `bun run db generate --name `. - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). + +# opencode Effect guide + +Instructions to follow when writing Effect. + +## Schemas + +- Use `Schema.Class` for data types with multiple fields. +- Use branded schemas (`Schema.brand`) for single-value types. + +## Services + +- Services use `ServiceMap.Service()("@console/")`. +- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object. + +## Errors + +- Use `Schema.TaggedErrorClass` for typed errors. +- For defect-like causes, use `Schema.Defect` instead of `unknown`. +- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. + +## Effects + +- Use `Effect.gen(function* () { ... })` for composition. +- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. +- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. + +## Time + +- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. + +## Errors + +- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts index e66b3c29928..35bfd1e3ed4 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -1,20 +1,24 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" + +import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema" import { Timestamps } from "../storage/schema.sql" export const AccountTable = sqliteTable("account", { - id: text().primaryKey(), + id: text().$type().primaryKey(), email: text().notNull(), url: text().notNull(), - access_token: text().notNull(), - refresh_token: text().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), ...Timestamps, }) export const AccountStateTable = sqliteTable("account_state", { id: integer().primaryKey(), - active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }), - active_org_id: text(), + active_account_id: text() + .$type() + .references(() => AccountTable.id, { onDelete: "set null" }), + active_org_id: text().$type(), }) // LEGACY @@ -23,8 +27,8 @@ export const ControlAccountTable = sqliteTable( { email: text().notNull(), url: text().notNull(), - access_token: text().notNull(), - refresh_token: text().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), active: integer({ mode: "boolean" }) .notNull() diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index b48ada1fb58..ed4c3d87988 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,4 +1,4 @@ -import { Effect, Option, ServiceMap } from "effect" +import { Effect, Option } from "effect" import { Account as AccountSchema, @@ -13,13 +13,11 @@ export { AccessToken, AccountID, OrgID } from "./service" import { runtime } from "@/effect/runtime" -type AccountServiceShape = ServiceMap.Service.Shape - -function runSync(f: (service: AccountServiceShape) => Effect.Effect) { +function runSync(f: (service: AccountService.Service) => Effect.Effect) { return runtime.runSync(AccountService.use(f)) } -function runPromise(f: (service: AccountServiceShape) => Effect.Effect) { +function runPromise(f: (service: AccountService.Service) => Effect.Effect) { return runtime.runPromise(AccountService.use(f)) } diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 65f56727b9b..5caf1a3b946 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -3,43 +3,16 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Database } from "@/storage/db" import { AccountStateTable, AccountTable } from "./account.sql" -import { Account, AccountID, AccountRepoError, OrgID } from "./schema" +import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema" export type AccountRow = (typeof AccountTable)["$inferSelect"] -const decodeAccount = Schema.decodeUnknownSync(Account) - type DbClient = Parameters[0] extends (db: infer T) => unknown ? T : never const ACCOUNT_STATE_ID = 1 -const db = (run: (db: DbClient) => A) => - Effect.try({ - try: () => Database.use(run), - catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), - }) - -const current = (db: DbClient) => { - const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() - if (!state?.active_account_id) return - const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() - if (!account) return - return { ...account, active_org_id: state.active_org_id ?? null } -} - -const setState = (db: DbClient, accountID: AccountID, orgID: string | null) => - db - .insert(AccountStateTable) - .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID }) - .onConflictDoUpdate({ - target: AccountStateTable.id, - set: { active_account_id: accountID, active_org_id: orgID }, - }) - .run() - -export class AccountRepo extends ServiceMap.Service< - AccountRepo, - { +export namespace AccountRepo { + export interface Service { readonly active: () => Effect.Effect, AccountRepoError> readonly list: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect @@ -47,62 +20,96 @@ export class AccountRepo extends ServiceMap.Service< readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> readonly persistToken: (input: { accountID: AccountID - accessToken: string - refreshToken: string + accessToken: AccessToken + refreshToken: RefreshToken expiry: Option.Option }) => Effect.Effect readonly persistAccount: (input: { id: AccountID email: string url: string - accessToken: string - refreshToken: string + accessToken: AccessToken + refreshToken: RefreshToken expiry: number orgID: Option.Option }) => Effect.Effect } ->()("@opencode/AccountRepo") { - static readonly layer: Layer.Layer = Layer.succeed( +} + +export class AccountRepo extends ServiceMap.Service()("@opencode/AccountRepo") { + static readonly layer: Layer.Layer = Layer.effect( AccountRepo, - AccountRepo.of({ - active: Effect.fn("AccountRepo.active")(() => - db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))), - ), + Effect.gen(function* () { + const decode = Schema.decodeUnknownSync(Account) + + const query = (f: (db: DbClient) => A) => + Effect.try({ + try: () => Database.use(f), + catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), + }) + + const tx = (f: (db: DbClient) => A) => + Effect.try({ + try: () => Database.transaction(f), + catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), + }) + + const current = (db: DbClient) => { + const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() + if (!state?.active_account_id) return + const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() + if (!account) return + return { ...account, active_org_id: state.active_org_id ?? null } + } + + const state = (db: DbClient, accountID: AccountID, orgID: Option.Option) => { + const id = Option.getOrNull(orgID) + return db + .insert(AccountStateTable) + .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) + .onConflictDoUpdate({ + target: AccountStateTable.id, + set: { active_account_id: accountID, active_org_id: id }, + }) + .run() + } - list: Effect.fn("AccountRepo.list")(() => - db((db) => + const active = Effect.fn("AccountRepo.active")(() => + query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), + ) + + const list = Effect.fn("AccountRepo.list")(() => + query((db) => db .select() .from(AccountTable) .all() - .map((row) => decodeAccount({ ...row, active_org_id: null })), + .map((row: AccountRow) => decode({ ...row, active_org_id: null })), ), - ), - - remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) => - db((db) => - Database.transaction((tx) => { - tx.update(AccountStateTable) - .set({ active_account_id: null, active_org_id: null }) - .where(eq(AccountStateTable.active_account_id, accountID)) - .run() - tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() - }), - ).pipe(Effect.asVoid), - ), + ) + + const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => + tx((db) => { + db.update(AccountStateTable) + .set({ active_account_id: null, active_org_id: null }) + .where(eq(AccountStateTable.active_account_id, accountID)) + .run() + db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() + }).pipe(Effect.asVoid), + ) - use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => - db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid), - ), + const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => + query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid), + ) - getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) => - db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( + const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => + query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( Effect.map(Option.fromNullishOr), ), - ), + ) - persistToken: Effect.fn("AccountRepo.persistToken")((input) => - db((db) => + const persistToken = Effect.fn("AccountRepo.persistToken")((input) => + query((db) => db .update(AccountTable) .set({ @@ -113,34 +120,41 @@ export class AccountRepo extends ServiceMap.Service< .where(eq(AccountTable.id, input.accountID)) .run(), ).pipe(Effect.asVoid), - ), - - persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => { - const orgID = Option.getOrNull(input.orgID) - return db((db) => - Database.transaction((tx) => { - tx.insert(AccountTable) - .values({ - id: input.id, - email: input.email, - url: input.url, + ) + + const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => + tx((db) => { + db.insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url: input.url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { access_token: input.accessToken, refresh_token: input.refreshToken, token_expiry: input.expiry, - }) - .onConflictDoUpdate({ - target: AccountTable.id, - set: { - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }, - }) - .run() - setState(tx, input.id, orgID) - }), - ).pipe(Effect.asVoid) - }), + }, + }) + .run() + void state(db, input.id, input.orgID) + }).pipe(Effect.asVoid), + ) + + return AccountRepo.of({ + active, + list, + remove, + use, + getRow, + persistToken, + persistAccount, + }) }), ) } diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 49c37932ff4..6b62484ab66 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -20,6 +20,24 @@ export const AccessToken = Schema.String.pipe( ) export type AccessToken = Schema.Schema.Type +export const RefreshToken = Schema.String.pipe( + Schema.brand("RefreshToken"), + withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })), +) +export type RefreshToken = Schema.Schema.Type + +export const DeviceCode = Schema.String.pipe( + Schema.brand("DeviceCode"), + withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })), +) +export type DeviceCode = Schema.Schema.Type + +export const UserCode = Schema.String.pipe( + Schema.brand("UserCode"), + withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })), +) +export type UserCode = Schema.Schema.Type + export class Account extends Schema.Class("Account")({ id: AccountID, email: Schema.String, @@ -45,12 +63,12 @@ export class AccountServiceError extends Schema.TaggedErrorClass("Login")({ - code: Schema.String, - user: Schema.String, + code: DeviceCode, + user: UserCode, url: Schema.String, server: Schema.String, - expiry: Schema.Number, - interval: Schema.Number, + expiry: Schema.Duration, + interval: Schema.Duration, }) {} export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/service.ts index ab1de72557f..87e95c8f444 100644 --- a/packages/opencode/src/account/service.ts +++ b/packages/opencode/src/account/service.ts @@ -1,11 +1,5 @@ -import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect" -import { - FetchHttpClient, - HttpClient, - HttpClientError, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http" +import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { AccountRepo, type AccountRow } from "./repo" @@ -14,6 +8,8 @@ import { AccessToken, Account, AccountID, + DeviceCode, + RefreshToken, AccountServiceError, Login, Org, @@ -25,83 +21,101 @@ import { type PollResult, PollSlow, PollSuccess, + UserCode, } from "./schema" export * from "./schema" export type AccountOrgs = { account: Account - orgs: Org[] + orgs: readonly Org[] } -const RemoteOrg = Schema.Struct({ - id: Schema.optional(OrgID), - name: Schema.optional(Schema.String), -}) +class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), +}) {} + +const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), +) + +class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, +}) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } +} -const RemoteOrgs = Schema.Array(RemoteOrg) +const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) -const RemoteConfig = Schema.Struct({ - config: Schema.Record(Schema.String, Schema.Json), -}) +class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, +}) {} -const TokenRefresh = Schema.Struct({ - access_token: Schema.String, - refresh_token: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), -}) +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} -const DeviceCode = Schema.Struct({ - device_code: Schema.String, - user_code: Schema.String, - verification_uri_complete: Schema.String, - expires_in: Schema.Number, - interval: Schema.Number, -}) - -const DeviceToken = Schema.Struct({ - access_token: Schema.optional(Schema.String), - refresh_token: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), - error: Schema.optional(Schema.String), - error_description: Schema.optional(Schema.String), -}) - -const User = Schema.Struct({ - id: Schema.optional(AccountID), - email: Schema.optional(Schema.String), -}) - -const ClientId = Schema.Struct({ client_id: Schema.String }) - -const DeviceTokenRequest = Schema.Struct({ +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ grant_type: Schema.String, - device_code: Schema.String, + device_code: DeviceCode, client_id: Schema.String, -}) +}) {} -const clientId = "opencode-cli" +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} -const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause }) +const clientId = "opencode-cli" const mapAccountServiceError = - (operation: string, message = "Account service operation failed") => + (message = "Account service operation failed") => (effect: Effect.Effect): Effect.Effect => effect.pipe( - Effect.mapError((error) => - error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error), + Effect.mapError((cause) => + cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), ), ) -export class AccountService extends ServiceMap.Service< - AccountService, - { +export namespace AccountService { + export interface Service { readonly active: () => Effect.Effect, AccountError> readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect readonly config: ( accountID: AccountID, orgID: OrgID, @@ -110,80 +124,98 @@ export class AccountService extends ServiceMap.Service< readonly login: (url: string) => Effect.Effect readonly poll: (input: Login) => Effect.Effect } ->()("@opencode/Account") { +} + +export class AccountService extends ServiceMap.Service()("@opencode/Account") { static readonly layer: Layer.Layer = Layer.effect( AccountService, Effect.gen(function* () { const repo = yield* AccountRepo const http = yield* HttpClient.HttpClient const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) - const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) => - http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed")) + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) => - httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed")) + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - const executeEffect = (operation: string, request: Effect.Effect) => + const executeEffectOk = (request: Effect.Effect) => request.pipe( - Effect.flatMap((req) => http.execute(req)), - mapAccountServiceError(operation, "HTTP request failed"), - ) - - const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.filterStatusOk(response).pipe( - Effect.map(Option.some), - Effect.catch((error) => - HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError" - ? Effect.succeed(Option.none()) - : Effect.fail(error), - ), - mapAccountServiceError(operation), + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), ) - const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) { + // Returns a usable access token for a stored account row, refreshing and + // persisting it when the cached token has expired. + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const now = yield* Clock.currentTimeMillis - if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token)) + if (row.token_expiry && row.token_expiry > now) return row.access_token - const response = yield* execute( - "token.refresh", - HttpClientRequest.post(`${found.url}/oauth/token`).pipe( + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.bodyUrlParams({ - grant_type: "refresh_token", - refresh_token: found.refresh_token, - }), + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id: clientId, + }), + ), ), ) - const ok = yield* okOrNone("token.refresh", response) - if (Option.isNone(ok)) return Option.none() - - const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe( - mapAccountServiceError("token.refresh", "Failed to decode response"), + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + mapAccountServiceError("Failed to decode response"), ) - const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000)) + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) yield* repo.persistToken({ - accountID: AccountID.make(found.id), + accountID: row.id, accessToken: parsed.access_token, - refreshToken: parsed.refresh_token ?? found.refresh_token, + refreshToken: parsed.refresh_token, expiry, }) - return Option.some(AccessToken.make(parsed.access_token)) + return parsed.access_token }) - const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) { + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>() + if (Option.isNone(maybeAccount)) return Option.none() const account = maybeAccount.value - const accessToken = yield* tokenForRow(account) - if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>() + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) - return Option.some({ account, accessToken: accessToken.value }) + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) }) const token = Effect.fn("AccountService.token")((accountID: AccountID) => @@ -211,23 +243,7 @@ export class AccountService extends ServiceMap.Service< const { account, accessToken } = resolved.value - const response = yield* executeRead( - "orgs", - HttpClientRequest.get(`${account.url}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - const ok = yield* okOrNone("orgs", response) - if (Option.isNone(ok)) return [] - - const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe( - mapAccountServiceError("orgs", "Failed to decode response"), - ) - return orgs - .filter((org) => org.id !== undefined && org.name !== undefined) - .map((org) => new Org({ id: org.id!, name: org.name! })) + return yield* fetchOrgs(account.url, accessToken) }) const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) { @@ -237,7 +253,6 @@ export class AccountService extends ServiceMap.Service< const { account, accessToken } = resolved.value const response = yield* executeRead( - "config", HttpClientRequest.get(`${account.url}/api/config`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), @@ -245,32 +260,26 @@ export class AccountService extends ServiceMap.Service< ), ) - const ok = yield* okOrNone("config", response) - if (Option.isNone(ok)) return Option.none() + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) - const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe( - mapAccountServiceError("config", "Failed to decode response"), + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( + mapAccountServiceError("Failed to decode response"), ) return Option.some(parsed.config) }) const login = Effect.fn("AccountService.login")(function* (server: string) { - const response = yield* executeEffect( - "login", + const response = yield* executeEffectOk( HttpClientRequest.post(`${server}/auth/device/code`).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }), + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), ), ) - const ok = yield* okOrNone("login", response) - if (Option.isNone(ok)) { - const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")) - return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`) - } - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe( - mapAccountServiceError("login", "Failed to decode response"), + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( + mapAccountServiceError("Failed to decode response"), ) return new Login({ code: parsed.device_code, @@ -283,91 +292,49 @@ export class AccountService extends ServiceMap.Service< }) const poll = Effect.fn("AccountService.poll")(function* (input: Login) { - const response = yield* executeEffect( - "poll", + const response = yield* executeEffectOk( HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: input.code, - client_id: clientId, - }), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( - mapAccountServiceError("poll", "Failed to decode response"), - ) - - if (!parsed.access_token) { - if (parsed.error === "authorization_pending") return new PollPending() - if (parsed.error === "slow_down") return new PollSlow() - if (parsed.error === "expired_token") return new PollExpired() - if (parsed.error === "access_denied") return new PollDenied() - return new PollError({ cause: parsed.error }) - } - - const access = parsed.access_token - - const fetchUser = executeRead( - "poll.user", - HttpClientRequest.get(`${input.server}/api/user`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(access), - ), - ).pipe( - Effect.flatMap((r) => - HttpClientResponse.schemaBodyJson(User)(r).pipe( - mapAccountServiceError("poll.user", "Failed to decode response"), + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: clientId, + }), ), ), ) - const fetchOrgs = executeRead( - "poll.orgs", - HttpClientRequest.get(`${input.server}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(access), - ), - ).pipe( - Effect.flatMap((r) => - HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe( - mapAccountServiceError("poll.orgs", "Failed to decode response"), - ), - ), + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( + mapAccountServiceError("Failed to decode response"), ) - const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 }) + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + const accessToken = parsed.access_token - const userId = user.id - const userEmail = user.email + const user = fetchUser(input.server, accessToken) + const orgs = fetchOrgs(input.server, accessToken) - if (!userId || !userEmail) { - return new PollError({ cause: "No id or email in response" }) - } + const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) - const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none() + // TODO: When there are multiple orgs, let the user choose + const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() const now = yield* Clock.currentTimeMillis - const expiry = now + (parsed.expires_in ?? 0) * 1000 - const refresh = parsed.refresh_token ?? "" - if (!refresh) { - yield* Effect.logWarning( - "Server did not return a refresh token — session may expire without ability to refresh", - ) - } + const expiry = now + Duration.toMillis(parsed.expires_in) + const refreshToken = parsed.refresh_token yield* repo.persistAccount({ - id: userId, - email: userEmail, + id: account.id, + email: account.email, url: input.server, - accessToken: access, - refreshToken: refresh, + accessToken, + refreshToken, expiry, orgID: firstOrgID, }) - return new PollSuccess({ email: userEmail }) + return new PollSuccess({ email: account.email }) }) return AccountService.of({ diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 7e9f893a8fb..dd0834a3d80 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -24,17 +24,17 @@ const loginEffect = Effect.fn("login")(function* (url: string) { const s = Prompt.spinner() yield* s.start("Waiting for authorization...") - const poll = (wait: number): Effect.Effect => + const poll = (wait: Duration.Duration): Effect.Effect => Effect.gen(function* () { yield* Effect.sleep(wait) const result = yield* service.poll(login) if (result._tag === "PollPending") return yield* poll(wait) - if (result._tag === "PollSlow") return yield* poll(wait + 5000) + if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5))) return result }) - const result = yield* poll(login.interval * 1000).pipe( - Effect.timeout(Duration.seconds(login.expiry)), + const result = yield* poll(login.interval).pipe( + Effect.timeout(login.expiry), Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())), ) diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index ecc392ead5e..74a6d7a570c 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -2,7 +2,7 @@ import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" import { AccountRepo } from "../../src/account/repo" -import { AccountID, OrgID } from "../../src/account/schema" +import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../fixture/effect" @@ -41,8 +41,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "at_123", - refreshToken: "rt_456", + accessToken: AccessToken.make("at_123"), + refreshToken: RefreshToken.make("rt_456"), expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), @@ -51,7 +51,7 @@ it.effect( const row = yield* AccountRepo.use((r) => r.getRow(id)) expect(Option.isSome(row)).toBe(true) const value = Option.getOrThrow(row) - expect(value.id).toBe("user-1") + expect(value.id).toBe(AccountID.make("user-1")) expect(value.email).toBe("test@example.com") const active = yield* AccountRepo.use((r) => r.active()) @@ -70,8 +70,8 @@ it.effect( id: id1, email: "first@example.com", url: "https://control.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), @@ -82,8 +82,8 @@ it.effect( id: id2, email: "second@example.com", url: "https://control.example.com", - accessToken: "at_2", - refreshToken: "rt_2", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-2")), }), @@ -108,8 +108,8 @@ it.effect( id: id1, email: "a@example.com", url: "https://control.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 3600_000, orgID: Option.none(), }), @@ -120,8 +120,8 @@ it.effect( id: id2, email: "b@example.com", url: "https://control.example.com", - accessToken: "at_2", - refreshToken: "rt_2", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), @@ -143,8 +143,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 3600_000, orgID: Option.none(), }), @@ -168,8 +168,8 @@ it.effect( id: id1, email: "first@example.com", url: "https://control.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 3600_000, orgID: Option.none(), }), @@ -180,8 +180,8 @@ it.effect( id: id2, email: "second@example.com", url: "https://control.example.com", - accessToken: "at_2", - refreshToken: "rt_2", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), expiry: Date.now() + 3600_000, orgID: Option.none(), }), @@ -208,8 +208,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "old_token", - refreshToken: "old_refresh", + accessToken: AccessToken.make("old_token"), + refreshToken: RefreshToken.make("old_refresh"), expiry: 1000, orgID: Option.none(), }), @@ -219,16 +219,16 @@ it.effect( yield* AccountRepo.use((r) => r.persistToken({ accountID: id, - accessToken: "new_token", - refreshToken: "new_refresh", + accessToken: AccessToken.make("new_token"), + refreshToken: RefreshToken.make("new_refresh"), expiry: Option.some(expiry), }), ) const row = yield* AccountRepo.use((r) => r.getRow(id)) const value = Option.getOrThrow(row) - expect(value.access_token).toBe("new_token") - expect(value.refresh_token).toBe("new_refresh") + expect(value.access_token).toBe(AccessToken.make("new_token")) + expect(value.refresh_token).toBe(RefreshToken.make("new_refresh")) expect(value.token_expiry).toBe(expiry) }), ) @@ -243,8 +243,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "old_token", - refreshToken: "old_refresh", + accessToken: AccessToken.make("old_token"), + refreshToken: RefreshToken.make("old_refresh"), expiry: 1000, orgID: Option.none(), }), @@ -253,8 +253,8 @@ it.effect( yield* AccountRepo.use((r) => r.persistToken({ accountID: id, - accessToken: "new_token", - refreshToken: "new_refresh", + accessToken: AccessToken.make("new_token"), + refreshToken: RefreshToken.make("new_refresh"), expiry: Option.none(), }), ) @@ -274,8 +274,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "at_v1", - refreshToken: "rt_v1", + accessToken: AccessToken.make("at_v1"), + refreshToken: RefreshToken.make("rt_v1"), expiry: 1000, orgID: Option.some(OrgID.make("org-1")), }), @@ -286,8 +286,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "at_v2", - refreshToken: "rt_v2", + accessToken: AccessToken.make("at_v2"), + refreshToken: RefreshToken.make("rt_v2"), expiry: 2000, orgID: Option.some(OrgID.make("org-2")), }), @@ -298,7 +298,7 @@ it.effect( const row = yield* AccountRepo.use((r) => r.getRow(id)) const value = Option.getOrThrow(row) - expect(value.access_token).toBe("at_v2") + expect(value.access_token).toBe(AccessToken.make("at_v2")) const active = yield* AccountRepo.use((r) => r.active()) expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2")) @@ -315,8 +315,8 @@ it.effect( id, email: "test@example.com", url: "https://control.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 87f5b23f282..5caa33235a1 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,10 +1,10 @@ import { expect } from "bun:test" -import { Effect, Layer, Option, Ref, Schema } from "effect" +import { Duration, Effect, Layer, Option, Ref, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" import { AccountService } from "../../src/account/service" -import { AccountID, Login, Org, OrgID } from "../../src/account/schema" +import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../fixture/effect" @@ -42,8 +42,8 @@ it.effect( id: AccountID.make("user-1"), email: "one@example.com", url: "https://one.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 60_000, orgID: Option.none(), }), @@ -54,8 +54,8 @@ it.effect( id: AccountID.make("user-2"), email: "two@example.com", url: "https://two.example.com", - accessToken: "at_2", - refreshToken: "rt_2", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), expiry: Date.now() + 60_000, orgID: Option.none(), }), @@ -101,8 +101,8 @@ it.effect( id, email: "user@example.com", url: "https://one.example.com", - accessToken: "at_old", - refreshToken: "rt_old", + accessToken: AccessToken.make("at_old"), + refreshToken: RefreshToken.make("rt_old"), expiry: Date.now() - 1_000, orgID: Option.none(), }), @@ -110,7 +110,7 @@ it.effect( const client = HttpClient.make((req) => Effect.succeed( - req.url === "https://one.example.com/oauth/token" + req.url === "https://one.example.com/auth/device/token" ? json(req, { access_token: "at_new", refresh_token: "rt_new", @@ -127,8 +127,8 @@ it.effect( const row = yield* AccountRepo.use((r) => r.getRow(id)) const value = Option.getOrThrow(row) - expect(value.access_token).toBe("at_new") - expect(value.refresh_token).toBe("rt_new") + expect(value.access_token).toBe(AccessToken.make("at_new")) + expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) expect(value.token_expiry).toBeGreaterThan(Date.now()) }), ) @@ -143,8 +143,8 @@ it.effect( id, email: "user@example.com", url: "https://one.example.com", - accessToken: "at_1", - refreshToken: "rt_1", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), expiry: Date.now() + 60_000, orgID: Option.none(), }), @@ -180,12 +180,12 @@ it.effect( "poll stores the account and first org on success", Effect.gen(function* () { const login = new Login({ - code: "device-code", - user: "user-code", + code: DeviceCode.make("device-code"), + user: UserCode.make("user-code"), url: "https://one.example.com/verify", server: "https://one.example.com", - expiry: 600, - interval: 5, + expiry: Duration.seconds(600), + interval: Duration.seconds(5), }) const client = HttpClient.make((req) => @@ -194,6 +194,7 @@ it.effect( ? json(req, { access_token: "at_1", refresh_token: "rt_1", + token_type: "Bearer", expires_in: 60, }) : req.url === "https://one.example.com/api/user" From cf7ca9b2f7f13fabd87e2ff41264d12ddd4f85ff Mon Sep 17 00:00:00 2001 From: Chris Yang <18487241+ysm-dev@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:40:06 +0900 Subject: [PATCH 006/357] fix(app): skip editor reconcile during IME composition (#17041) --- packages/app/src/components/prompt-input.tsx | 47 +++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 532edd3bcdc..3ee8f43513b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -490,6 +490,18 @@ export const PromptInput: Component = (props) => { setComposing(false) } + const handleCompositionStart = () => { + setComposing(true) + } + + const handleCompositionEnd = () => { + setComposing(false) + requestAnimationFrame(() => { + if (composing()) return + reconcile(prompt.current().filter((part) => part.type !== "image")) + }) + } + const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -680,24 +692,27 @@ export const PromptInput: Component = (props) => { } } - createEffect( - on( - () => prompt.current(), - (currentParts) => { - const inputParts = currentParts.filter((part) => part.type !== "image") + const reconcile = (input: Prompt) => { + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return - if (mirror.input) { - mirror.input = false - if (isNormalizedEditor()) return + renderEditorWithCursor(input) + return + } - renderEditorWithCursor(inputParts) - return - } + const dom = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(input, dom)) return - const domParts = parseFromDOM() - if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return + renderEditorWithCursor(input) + } - renderEditorWithCursor(inputParts) + createEffect( + on( + () => prompt.current(), + (parts) => { + if (composing()) return + reconcile(parts.filter((part) => part.type !== "image")) }, ), ) @@ -1208,8 +1223,8 @@ export const PromptInput: Component = (props) => { spellcheck={store.mode === "normal"} onInput={handleInput} onPaste={handlePaste} - onCompositionStart={() => setComposing(true)} - onCompositionEnd={() => setComposing(false)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} onBlur={handleBlur} onKeyDown={handleKeyDown} classList={{ From c37f7b9d997fcbbf4f331e1965425375e7606ab6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:42:29 -0500 Subject: [PATCH 007/357] fix(app): todos not clearing --- .../composer/session-composer-helpers.ts | 10 ++++ .../composer/session-composer-state.test.ts | 23 ++++++++ .../composer/session-composer-state.ts | 58 ++++++++++++++----- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/pages/session/composer/session-composer-helpers.ts diff --git a/packages/app/src/pages/session/composer/session-composer-helpers.ts b/packages/app/src/pages/session/composer/session-composer-helpers.ts new file mode 100644 index 00000000000..90c238af46d --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-helpers.ts @@ -0,0 +1,10 @@ +export const todoState = (input: { + count: number + done: boolean + live: boolean +}): "hide" | "clear" | "open" | "close" => { + if (input.count === 0) return "hide" + if (!input.live) return "clear" + if (!input.done) return "open" + return "close" +} diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 934d3152a94..f7c11715c2b 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { todoState } from "./session-composer-helpers" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" const session = (input: { id: string; parentID?: string }) => @@ -103,3 +104,25 @@ describe("sessionQuestionRequest", () => { expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") }) }) + +describe("todoState", () => { + test("hides when there are no todos", () => { + expect(todoState({ count: 0, done: false, live: true })).toBe("hide") + }) + + test("opens while the session is still working", () => { + expect(todoState({ count: 2, done: false, live: true })).toBe("open") + }) + + test("closes completed todos after a running turn", () => { + expect(todoState({ count: 2, done: true, live: true })).toBe("close") + }) + + test("clears stale todos when the turn ends", () => { + expect(todoState({ count: 2, done: false, live: false })).toBe("clear") + }) + + test("clears completed todos when the session is no longer live", () => { + expect(todoState({ count: 2, done: true, live: false })).toBe("clear") + }) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index f70bc4bbdd0..a007e4c8491 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -8,8 +8,11 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { todoState } from "./session-composer-helpers" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +const idle = { type: "idle" as const } + export function createSessionComposerBlocked() { const params = useParams() const permission = usePermission() @@ -59,9 +62,22 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return globalSync.data.session_todo[id] ?? [] }) + const done = createMemo( + () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + const status = createMemo(() => { + const id = params.id + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) + + const busy = createMemo(() => status().type !== "idle") + const live = createMemo(() => busy() || blocked()) + const [store, setStore] = createStore({ responding: undefined as string | undefined, - dock: todos().length > 0, + dock: todos().length > 0 && live(), closing: false, opening: false, }) @@ -89,10 +105,6 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }) } - const done = createMemo( - () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), - ) - let timer: number | undefined let raf: number | undefined @@ -111,21 +123,42 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }, closeMs()) } + // Keep stale turn todos from reopening if the model never clears them. + const clear = () => { + const id = params.id + if (!id) return + globalSync.todo.set(id, []) + sync.set("todo", id, []) + } + createEffect( on( - () => [todos().length, done()] as const, - ([count, complete], prev) => { + () => [todos().length, done(), live()] as const, + ([count, complete, active]) => { if (raf) cancelAnimationFrame(raf) raf = undefined - if (count === 0) { + const next = todoState({ + count, + done: complete, + live: active, + }) + + if (next === "hide") { if (timer) window.clearTimeout(timer) timer = undefined setStore({ dock: false, closing: false, opening: false }) return } - if (!complete) { + if (next === "clear") { + if (timer) window.clearTimeout(timer) + timer = undefined + clear() + return + } + + if (next === "open") { if (timer) window.clearTimeout(timer) timer = undefined const hidden = !store.dock || store.closing @@ -142,13 +175,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return } - if (prev && prev[1]) { - if (store.closing && !timer) scheduleClose() - return - } - setStore({ dock: true, opening: false, closing: true }) - scheduleClose() + if (!timer) scheduleClose() }, ), ) From dbc00aa8e04cd9fbfb0534e24751c38232862244 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 11 Mar 2026 16:44:26 -0400 Subject: [PATCH 008/357] feat(id): brand ProjectID through Drizzle and Zod schemas (#16948) --- packages/opencode/src/cli/cmd/import.ts | 4 +- packages/opencode/src/control-plane/types.ts | 3 +- .../src/control-plane/workspace.sql.ts | 2 + .../opencode/src/control-plane/workspace.ts | 3 +- packages/opencode/src/permission/next.ts | 3 +- packages/opencode/src/project/project.sql.ts | 3 +- packages/opencode/src/project/project.ts | 43 ++++++++++--------- packages/opencode/src/project/schema.ts | 16 +++++++ .../opencode/src/server/routes/project.ts | 3 +- packages/opencode/src/session/index.ts | 5 ++- packages/opencode/src/session/session.sql.ts | 2 + packages/opencode/src/worktree/index.ts | 5 ++- packages/opencode/test/config/config.test.ts | 3 +- .../opencode/test/project/project.test.ts | 9 ++-- .../test/storage/json-migration.test.ts | 17 ++++---- 15 files changed, 77 insertions(+), 44 deletions(-) create mode 100644 packages/opencode/src/project/schema.ts diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index eb5964379a8..cfea5fdbb39 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -86,7 +86,7 @@ export const ImportCommand = cmd({ await bootstrap(process.cwd(), async () => { let exportData: | { - info: Session.Info + info: SDKSession messages: Array<{ info: Message parts: Part[] @@ -152,7 +152,7 @@ export const ImportCommand = cmd({ return } - const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id } + const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id }) Database.use((db) => db .insert(SessionTable) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 3d27757fd1b..53a6c8a0732 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import z from "zod" import { Identifier } from "@/id/id" +import { ProjectID } from "@/project/schema" export const WorkspaceInfo = z.object({ id: Identifier.schema("workspace"), @@ -8,7 +9,7 @@ export const WorkspaceInfo = z.object({ name: z.string().nullable(), directory: z.string().nullable(), extra: z.unknown().nullable(), - projectID: z.string(), + projectID: ProjectID.zod, }) export type WorkspaceInfo = z.infer diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 7639620691e..eb7d21d1e89 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,5 +1,6 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" +import type { ProjectID } from "../project/schema" export const WorkspaceTable = sqliteTable("workspace", { id: text().primaryKey(), @@ -9,6 +10,7 @@ export const WorkspaceTable = sqliteTable("workspace", { directory: text(), extra: text({ mode: "json" }), project_id: text() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8c76fbdab99..f3af985cac6 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -6,6 +6,7 @@ import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Log } from "@/util/log" +import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" @@ -48,7 +49,7 @@ export namespace Workspace { id: Identifier.schema("workspace").optional(), type: Info.shape.type, branch: Info.shape.branch, - projectID: Info.shape.projectID, + projectID: ProjectID.zod, extra: Info.shape.extra, }) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce..4e47e43c8f6 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -7,6 +7,7 @@ import { Database, eq } from "@/storage/db" import { PermissionTable } from "@/session/session.sql" import { fn } from "@/util/fn" import { Log } from "@/util/log" +import { ProjectID } from "@/project/schema" import { Wildcard } from "@/util/wildcard" import os from "os" import z from "zod" @@ -90,7 +91,7 @@ export namespace PermissionNext { export type Reply = z.infer export const Approval = z.object({ - projectID: z.string(), + projectID: ProjectID.zod, patterns: z.string().array(), }) diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index 7f0f8ca5323..efbc400b5ee 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -1,8 +1,9 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { Timestamps } from "../storage/schema.sql" +import type { ProjectID } from "./schema" export const ProjectTable = sqliteTable("project", { - id: text().primaryKey(), + id: text().$type().primaryKey(), worktree: text().notNull(), vcs: text(), name: text(), diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 68cece0a52a..196dc8da619 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,6 +15,7 @@ import { existsSync } from "fs" import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" +import { ProjectID } from "./schema" export namespace Project { const log = Log.create({ service: "project" }) @@ -33,7 +34,7 @@ export namespace Project { export const Info = z .object({ - id: z.string(), + id: ProjectID.zod, worktree: z.string(), vcs: z.literal("git").optional(), name: z.string().optional(), @@ -73,7 +74,7 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: row.id, + id: ProjectID.make(row.id), worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, @@ -91,6 +92,7 @@ export namespace Project { function readCachedId(dir: string) { return Filesystem.readText(path.join(dir, "opencode")) .then((x) => x.trim()) + .then(ProjectID.make) .catch(() => undefined) } @@ -111,7 +113,7 @@ export namespace Project { if (!gitBinary) { return { - id: id ?? "global", + id: id ?? ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -130,7 +132,7 @@ export namespace Project { if (!worktree) { return { - id: id ?? "global", + id: id ?? ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -160,14 +162,14 @@ export namespace Project { if (!roots) { return { - id: "global", + id: ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } } - id = roots[0] + id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) } @@ -175,7 +177,7 @@ export namespace Project { if (!id) { return { - id: "global", + id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git", @@ -208,7 +210,7 @@ export namespace Project { } return { - id: "global", + id: ProjectID.global, worktree: "/", sandbox: "/", vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -228,7 +230,7 @@ export namespace Project { updated: Date.now(), }, } - if (data.id !== "global") { + if (data.id !== ProjectID.global) { await migrateFromGlobal(data.id, data.worktree) } return fresh @@ -308,12 +310,12 @@ export namespace Project { return } - async function migrateFromGlobal(id: string, worktree: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get()) + async function migrateFromGlobal(id: ProjectID, worktree: string) { + const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, ProjectID.global)).get()) if (!row) return const sessions = Database.use((db) => - db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(), + db.select().from(SessionTable).where(eq(SessionTable.project_id, ProjectID.global)).all(), ) if (sessions.length === 0) return @@ -323,14 +325,14 @@ export namespace Project { // Skip sessions that belong to a different directory if (row.directory && row.directory !== worktree) return - log.info("migrating session", { sessionID: row.id, from: "global", to: id }) + log.info("migrating session", { sessionID: row.id, from: ProjectID.global, to: id }) Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run()) }).catch((error) => { log.error("failed to migrate sessions from global to project", { error, projectId: id }) }) } - export function setInitialized(id: string) { + export function setInitialized(id: ProjectID) { Database.use((db) => db .update(ProjectTable) @@ -352,7 +354,7 @@ export namespace Project { ) } - export function get(id: string): Info | undefined { + export function get(id: ProjectID): Info | undefined { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return undefined return fromRow(row) @@ -375,12 +377,13 @@ export namespace Project { export const update = fn( z.object({ - projectID: z.string(), + projectID: ProjectID.zod, name: z.string().optional(), icon: Info.shape.icon.optional(), commands: Info.shape.commands.optional(), }), async (input) => { + const id = ProjectID.make(input.projectID) const result = Database.use((db) => db .update(ProjectTable) @@ -391,7 +394,7 @@ export namespace Project { commands: input.commands, time_updated: Date.now(), }) - .where(eq(ProjectTable.id, input.projectID)) + .where(eq(ProjectTable.id, id)) .returning() .get(), ) @@ -407,7 +410,7 @@ export namespace Project { }, ) - export async function sandboxes(id: string) { + export async function sandboxes(id: ProjectID) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] const data = fromRow(row) @@ -419,7 +422,7 @@ export namespace Project { return valid } - export async function addSandbox(id: string, directory: string) { + export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) const sandboxes = [...row.sandboxes] @@ -443,7 +446,7 @@ export namespace Project { return data } - export async function removeSandbox(id: string, directory: string) { + export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) const sandboxes = row.sandboxes.filter((s) => s !== directory) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts new file mode 100644 index 00000000000..adaa9a8314a --- /dev/null +++ b/packages/opencode/src/project/schema.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect" +import z from "zod" + +import { withStatics } from "@/util/schema" + +const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId")) + +export type ProjectID = typeof projectIdSchema.Type + +export const ProjectID = projectIdSchema.pipe( + withStatics((schema: typeof projectIdSchema) => ({ + global: schema.makeUnsafe("global"), + make: (id: string) => schema.makeUnsafe(id), + zod: z.string().pipe(z.custom()), + })), +) diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 85314df9371..994d58b0ca1 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -4,6 +4,7 @@ import { resolver } from "hono-openapi" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import z from "zod" +import { ProjectID } from "../../project/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" import { InstanceBootstrap } from "../../project/bootstrap" @@ -105,7 +106,7 @@ export const ProjectRoutes = lazy(() => ...errors(400, 404), }, }), - validator("param", z.object({ projectID: z.string() })), + validator("param", z.object({ projectID: ProjectID.zod })), validator("json", Project.update.schema.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5cc4d7da8d3..ff499fe2e77 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -23,6 +23,7 @@ import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" +import { ProjectID } from "../project/schema" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" @@ -120,7 +121,7 @@ export namespace Session { .object({ id: Identifier.schema("session"), slug: z.string(), - projectID: z.string(), + projectID: ProjectID.zod, workspaceID: z.string().optional(), directory: z.string(), parentID: Identifier.schema("session").optional(), @@ -162,7 +163,7 @@ export namespace Session { export const ProjectInfo = z .object({ - id: z.string(), + id: ProjectID.zod, name: z.string().optional(), worktree: z.string(), }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index b3228f400fa..0b62dbbd8c2 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -3,6 +3,7 @@ import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" import type { PermissionNext } from "../permission/next" +import type { ProjectID } from "../project/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit @@ -13,6 +14,7 @@ export const SessionTable = sqliteTable( { id: text().primaryKey(), project_id: text() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), workspace_id: text(), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index aa5613010cc..6ed0e482024 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -8,6 +8,7 @@ import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" +import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" @@ -310,7 +311,7 @@ export namespace Worktree { return false } - async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) { + async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()) const project = row ? Project.fromRow(row) : undefined const startup = project?.commands?.start?.trim() ?? "" @@ -322,7 +323,7 @@ export namespace Worktree { return true } - function queueStartScripts(directory: string, input: { projectID: string; extra?: string }) { + function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) { setTimeout(() => { const start = async () => { await runStartScripts(directory, input) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 80394fbff50..90727cf8a08 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,6 +8,7 @@ import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" +import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" // Get managed config directory from environment (set in preload.ts) @@ -44,7 +45,7 @@ async function check(map: (dir: string) => string) { const cfg = await Config.get() expect(cfg.snapshot).toBe(true) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) - expect(Instance.project.id).not.toBe("global") + expect(Instance.project.id).not.toBe(ProjectID.global) }, }) } finally { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index fef9e4190e2..cc6b8dde5b8 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -6,6 +6,7 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" +import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) @@ -74,7 +75,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project).toBeDefined() - expect(project.id).toBe("global") + expect(project.id).toBe(ProjectID.global) expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp.path) @@ -90,7 +91,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project).toBeDefined() - expect(project.id).not.toBe("global") + expect(project.id).not.toBe(ProjectID.global) expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp.path) @@ -107,7 +108,7 @@ describe("Project.fromDirectory", () => { await withMode("rev-list-fail", async () => { const { project } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.id).toBe("global") + expect(project.id).toBe(ProjectID.global) expect(project.worktree).toBe(tmp.path) }) }) @@ -301,7 +302,7 @@ describe("Project.update", () => { await expect( Project.update({ - projectID: "nonexistent-project-id", + projectID: ProjectID.make("nonexistent-project-id"), name: "Should Fail", }), ).rejects.toThrow("Project not found: nonexistent-project-id") diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 40dd6114538..76dfb8b4502 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -8,6 +8,7 @@ import { readFileSync, readdirSync } from "fs" import { JsonMigration } from "../../src/storage/json-migration" import { Global } from "../../src/global" import { ProjectTable } from "../../src/project/project.sql" +import { ProjectID } from "../../src/project/schema" import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" import { SessionShareTable } from "../../src/share/share.sql" @@ -123,7 +124,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_test123abc") + expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) expect(projects[0].worktree).toBe("/test/path") expect(projects[0].name).toBe("Test Project") expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) @@ -148,7 +149,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id + expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id }) test("migrates project with commands", async () => { @@ -169,7 +170,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_with_commands") + expect(projects[0].id).toBe(ProjectID.make("proj_with_commands")) expect(projects[0].commands).toEqual({ start: "npm run dev" }) }) @@ -190,7 +191,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_no_commands") + expect(projects[0].id).toBe(ProjectID.make("proj_no_commands")) expect(projects[0].commands).toBeNull() }) @@ -220,7 +221,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_test456def") - expect(sessions[0].project_id).toBe("proj_test123abc") + expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) expect(sessions[0].slug).toBe("test-session") expect(sessions[0].title).toBe("Test Session Title") expect(sessions[0].summary_additions).toBe(10) @@ -426,7 +427,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_migrated") - expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON + expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON }) test("uses filename for session id when JSON has different value", async () => { @@ -458,7 +459,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id - expect(sessions[0].project_id).toBe("proj_test123abc") + expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) }) test("is idempotent (running twice doesn't duplicate)", async () => { @@ -643,7 +644,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_test123abc") + expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) }) test("skips invalid todo entries while preserving source positions", async () => { From 387ab78bf69a130b87e8d428e796c1663ba6e745 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:01:56 -0500 Subject: [PATCH 009/357] chore: fix test --- .../app/e2e/session/session-review.spec.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 4198c733c0d..89d04e67d93 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -101,12 +101,31 @@ async function waitMark(page: Parameters[0]["page"], file: string, ) } +async function spot(page: Parameters[0]["page"], file: string) { + return page.evaluate((file) => { + const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport') + if (!(view instanceof HTMLElement)) return null + + const row = Array.from(document.querySelectorAll("h3")).find( + (node) => node instanceof HTMLElement && node.textContent?.includes(file), + ) + if (!(row instanceof HTMLElement)) return null + + const a = row.getBoundingClientRect() + const b = view.getBoundingClientRect() + return { + top: a.top - b.top, + y: view.scrollTop, + } + }, file) +} + test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test.setTimeout(180_000) const tag = `review-${Date.now()}` const list = files(tag) - const hit = list[list.length - 2]! + const hit = list[list.length - 4]! const next = `${tag}-live` await page.setViewportSize({ width: 1600, height: 1000 }) @@ -160,8 +179,9 @@ test("review keeps scroll position after a live diff update", async ({ page, wit await expect(row).toBeVisible() await row.evaluate((el) => el.scrollIntoView({ block: "center" })) - await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200) - const prev = await view.evaluate((el) => el.scrollTop) + await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) + const prev = await spot(page, hit.file) + if (!prev) throw new Error(`missing review row for ${hit.file}`) await patch(sdk, session.id, edit(hit.file, hit.mark, next)) @@ -179,8 +199,15 @@ test("review keeps scroll position after a live diff update", async ({ page, wit await waitMark(page, hit.file, next) await expect - .poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 }) - .toBeLessThanOrEqual(16) + .poll( + async () => { + const next = await spot(page, hit.file) + if (!next) return Number.POSITIVE_INFINITY + return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) + }, + { timeout: 60_000 }, + ) + .toBeLessThanOrEqual(32) }) }) }) From f96e2d4222d70849ef8d4992565dc94e64832243 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:03:15 -0500 Subject: [PATCH 010/357] tweak: adjust skill presentation to be a little less token heavy (#17098) --- packages/opencode/src/session/system.ts | 4 ++- packages/opencode/src/skill/skill.ts | 30 ++++++++++++++--------- packages/opencode/src/tool/skill.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 2 +- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 0f0f6b51b3c..a4c4684ffee 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -64,7 +64,9 @@ export namespace SystemPrompt { return [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", - list.length === 0 ? "No skills are currently available." : "\n" + Skill.fmt(list), + // the agents seem to ingest the information about skills a bit better if we present a more verbose + // version of them here and a less verbose version in tool description, rather than vice versa. + Skill.fmt(list, { verbose: true }), ].join("\n") } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 09cc787c802..fa984b3e111 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -196,17 +196,23 @@ export namespace Skill { return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") } - export function fmt(list: Info[]) { - return [ - "", - ...list.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - ` `, - ]), - "", - ].join("\n") + export function fmt(list: Info[], opts: { verbose: boolean }) { + if (list.length === 0) { + return "No skills are currently available." + } + if (opts.verbose) { + return [ + "", + ...list.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + ` `, + ]), + "", + ].join("\n") + } + return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 6d2a48b0ed2..17016b06f80 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -24,7 +24,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { "The following skills provide specialized sets of instructions for particular tasks", "Invoke this tool to load a skill when a task matches one of the available skills listed below:", "", - Skill.fmt(list), + Skill.fmt(list, { verbose: false }), ].join("\n") const examples = list diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index d5057ba9e7f..c47259f1554 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -45,7 +45,7 @@ description: Skill for tool tests. fn: async () => { const tool = await SkillTool.init() const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md") - expect(tool.description).toContain(`${pathToFileURL(skillPath).href}`) + expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`) }, }) } finally { From a1cda29012e9521b1453950b5490fe18db1db4d5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:10:58 -0500 Subject: [PATCH 011/357] chore: fix test --- packages/app/e2e/session/session-review.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 89d04e67d93..fbca8d33ad1 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -85,7 +85,10 @@ async function expand(page: Parameters[0]["page"]) { async function waitMark(page: Parameters[0]["page"], file: string, mark: string) { await page.waitForFunction( ({ file, mark }) => { - const head = Array.from(document.querySelectorAll("h3")).find( + const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport') + if (!(view instanceof HTMLElement)) return false + + const head = Array.from(view.querySelectorAll("h3")).find( (node) => node instanceof HTMLElement && node.textContent?.includes(file), ) if (!(head instanceof HTMLElement)) return false @@ -106,7 +109,7 @@ async function spot(page: Parameters[0]["page"], file: string) { const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport') if (!(view instanceof HTMLElement)) return null - const row = Array.from(document.querySelectorAll("h3")).find( + const row = Array.from(view.querySelectorAll("h3")).find( (node) => node instanceof HTMLElement && node.textContent?.includes(file), ) if (!(row instanceof HTMLElement)) return null From 440405dbddd62c57d983fdcf09d9ed55cc6711a8 Mon Sep 17 00:00:00 2001 From: Noam Bressler Date: Wed, 11 Mar 2026 23:18:40 +0200 Subject: [PATCH 012/357] fix: re-enable snapshot in acp (#14918) --- packages/opencode/src/snapshot/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 848f2694e01..72252b7b4c5 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -29,7 +29,7 @@ export namespace Snapshot { } export async function cleanup() { - if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return + if (Instance.project.vcs !== "git") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() @@ -54,7 +54,7 @@ export namespace Snapshot { } export async function track() { - if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return + if (Instance.project.vcs !== "git") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() From 58f45ae22b0faeba4a98910b0d3b42146846462b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:20:56 -0500 Subject: [PATCH 013/357] chore: skip test --- packages/app/e2e/session/session-review.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index fbca8d33ad1..28c85edb0b1 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -124,6 +124,7 @@ async function spot(page: Parameters[0]["page"], file: string) { } test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { + test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.setTimeout(180_000) const tag = `review-${Date.now()}` From fbd9b7cf4fb27433a066a10a197c7edb420336d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:34:48 -0500 Subject: [PATCH 014/357] feat(app): restore to message and fork session (#17092) --- packages/app/src/i18n/en.ts | 5 + packages/app/src/pages/session.tsx | 116 ++++++++++++++++++ .../composer/session-composer-region.tsx | 40 +++++- .../session/composer/session-revert-dock.tsx | 92 ++++++++++++++ .../src/pages/session/message-timeline.tsx | 7 ++ packages/ui/src/components/icon.tsx | 2 + packages/ui/src/components/message-part.tsx | 59 +++++++++ packages/ui/src/components/session-turn.tsx | 11 +- packages/ui/src/i18n/en.ts | 2 + 9 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/pages/session/composer/session-revert-dock.tsx diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 97a572f1cf2..c87e7cb9dbb 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -530,6 +530,11 @@ export const dict = { "session.todo.title": "Todos", "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.revertDock.summary.one": "{{count}} rolled back message", + "session.revertDock.summary.other": "{{count}} rolled back messages", + "session.revertDock.collapse": "Collapse rolled back messages", + "session.revertDock.expand": "Expand rolled back messages", + "session.revertDock.restore": "Restore message", "session.new.title": "Build anything", "session.new.worktree.main": "Main branch", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a5c7bf90b32..1b62b94294c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -43,6 +43,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" @@ -286,6 +287,7 @@ export default function Page() { const [ui, setUi] = createStore({ git: false, pendingMessage: undefined as string | undefined, + restoring: undefined as string | undefined, reviewSnap: false, scrollGesture: 0, scroll: { @@ -1179,6 +1181,110 @@ export default function Page() { scroller: () => scroller, }) + const draft = (id: string) => + extractPromptFromParts(sync.data.part[id] ?? [], { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) + + const line = (id: string) => { + const text = draft(id) + .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) + .join("") + .replace(/\s+/g, " ") + .trim() + if (text) return text + return `[${language.t("common.attachment")}]` + } + + const fail = (err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: formatServerError(err, language.t), + }) + } + + const busy = (sessionID: string) => { + if (sync.data.session_status[sessionID]?.type !== "idle") return true + return (sync.data.message[sessionID] ?? []).some( + (item) => item.role === "assistant" && typeof item.time.completed !== "number", + ) + } + + const halt = (sessionID: string) => + busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() + + const fork = (input: { sessionID: string; messageID: string }) => { + const value = draft(input.messageID) + return sdk.client.session + .fork(input) + .then((result) => { + const next = result.data + if (!next) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + }) + return + } + navigate(`/${base64Encode(sdk.directory)}/session/${next.id}`) + requestAnimationFrame(() => { + prompt.set(value) + }) + }) + .catch(fail) + } + + const revert = (input: { sessionID: string; messageID: string }) => { + const value = draft(input.messageID) + return halt(input.sessionID) + .then(() => sdk.client.session.revert(input)) + .then(() => { + prompt.set(value) + }) + .catch(fail) + } + + const restore = (id: string) => { + const sessionID = params.id + if (!sessionID || ui.restoring) return + + const next = userMessages().find((item) => item.id > id) + setUi("restoring", id) + + const task = !next + ? halt(sessionID) + .then(() => sdk.client.session.unrevert({ sessionID })) + .then(() => { + prompt.reset() + }) + : halt(sessionID) + .then(() => + sdk.client.session.revert({ + sessionID, + messageID: next.id, + }), + ) + .then(() => { + prompt.set(draft(next.id)) + }) + + return task.catch(fail).finally(() => { + setUi("restoring", (value) => (value === id ? undefined : value)) + }) + } + + const rolled = createMemo(() => { + const id = revertMessageID() + if (!id) return [] + return userMessages() + .filter((item) => item.id >= id) + .map((item) => ({ id: item.id, text: line(item.id) })) + }) + + const actions = { fork, revert } + createResizeObserver( () => promptDock, ({ height }) => { @@ -1268,6 +1374,7 @@ export default function Page() { loadingClass: "px-4 py-4 text-text-weak", emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", })} + actions={actions} scroll={ui.scroll} onResumeScroll={resumeScroll} setScrollRef={setScrollRef} @@ -1333,6 +1440,15 @@ export default function Page() { resumeScroll() }} onResponseSubmit={resumeScroll} + revert={ + rolled().length > 0 + ? { + items: rolled(), + restoring: ui.restoring, + onRestore: restore, + } + : undefined + } setPromptDockRef={(el) => { promptDock = el }} diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 93ea3d465c5..08746b51a56 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -8,6 +8,7 @@ import { usePrompt } from "@/context/prompt" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" +import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" @@ -20,6 +21,11 @@ export function SessionComposerRegion(props: { onNewSessionWorktreeReset: () => void onSubmit: () => void onResponseSubmit: () => void + revert?: { + items: { id: string; text: string }[] + restoring?: string + onRestore: (id: string) => void + } setPromptDockRef: (el: HTMLDivElement) => void visualDuration?: number bounce?: number @@ -116,6 +122,8 @@ export function SessionComposerRegion(props: { const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const [height, setHeight] = createSignal(320) const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) + const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) + const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const full = createMemo(() => Math.max(78, height())) const [contentRef, setContentRef] = createSignal() @@ -170,9 +178,22 @@ export function SessionComposerRegion(props: { - {handoffPrompt() || language.t("prompt.loading")} -
+ <> + + {(revert) => ( +
+ +
+ )} +
+
+ {handoffPrompt() || language.t("prompt.loading")} +
+ } > @@ -209,12 +230,23 @@ export function SessionComposerRegion(props: {
+ + {(revert) => ( +
+ +
+ )} +
void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ + collapsed: false, + }) + + const toggle = () => setStore("collapsed", (value) => !value) + const total = createMemo(() => props.items.length) + const label = createMemo(() => + language.t(total() === 1 ? "session.revertDock.summary.one" : "session.revertDock.summary.other", { + count: total(), + }), + ) + const preview = createMemo(() => props.items[0]?.text ?? "") + + return ( + +
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + {label()} + + {preview()} + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} + aria-label={ + store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse") + } + /> +
+
+ + +
@@ -728,7 +745,7 @@ export const Playground = { max={2.2} step={0.05} value={bounce()} - onInput={(e) => setBounce(Number(e.currentTarget.value))} + onInput={(e) => setState("bounce", Number(e.currentTarget.value))} /> {bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""} @@ -744,7 +761,7 @@ export const Playground = { max={50} step={1} value={maskSize()} - onInput={(e) => setMaskSize(Number(e.currentTarget.value))} + onInput={(e) => setState("maskSize", Number(e.currentTarget.value))} /> {maskSize()}px {maskSize() === 0 ? "(hard)" : ""} @@ -760,7 +777,7 @@ export const Playground = { max={60} step={1} value={maskPad()} - onInput={(e) => setMaskPad(Number(e.currentTarget.value))} + onInput={(e) => setState("maskPad", Number(e.currentTarget.value))} /> {maskPad()}px
@@ -774,7 +791,7 @@ export const Playground = { max={80} step={1} value={maskHeight()} - onInput={(e) => setMaskHeight(Number(e.currentTarget.value))} + onInput={(e) => setState("maskHeight", Number(e.currentTarget.value))} /> {maskHeight()}px
@@ -795,13 +812,13 @@ export const Playground = { - - - @@ -810,8 +827,8 @@ export const Playground = { {HEADINGS.map((h, i) => ( {[0, 1, 2, 3].map((value) => ( - ))} @@ -307,7 +328,7 @@ export const Playground = { max="1" step="0.01" value={dockOpenDuration()} - onInput={(event) => setDockOpenDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -324,7 +345,7 @@ export const Playground = { max="1" step="0.01" value={dockOpenBounce()} - onInput={(event) => setDockOpenBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -345,7 +366,7 @@ export const Playground = { max="1" step="0.01" value={dockCloseDuration()} - onInput={(event) => setDockCloseDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -362,7 +383,7 @@ export const Playground = { max="1" step="0.01" value={dockCloseBounce()} - onInput={(event) => setDockCloseBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -383,7 +404,7 @@ export const Playground = { max="1" step="0.01" value={drawerExpandDuration()} - onInput={(event) => setDrawerExpandDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -400,7 +421,7 @@ export const Playground = { max="1" step="0.01" value={drawerExpandBounce()} - onInput={(event) => setDrawerExpandBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -421,7 +442,7 @@ export const Playground = { max="1" step="0.01" value={drawerCollapseDuration()} - onInput={(event) => setDrawerCollapseDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -438,7 +459,7 @@ export const Playground = { max="1" step="0.01" value={drawerCollapseBounce()} - onInput={(event) => setDrawerCollapseBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -459,7 +480,7 @@ export const Playground = { max="1400" step="10" value={subtitleDuration()} - onInput={(event) => setSubtitleDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -473,7 +494,7 @@ export const Playground = { setSubtitleAuto(event.currentTarget.checked)} + onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)} /> {subtitleAuto() ? "on" : "off"} @@ -489,7 +510,7 @@ export const Playground = { max="40" step="1" value={subtitleTravel()} - onInput={(event) => setSubtitleTravel(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {subtitleTravel()}px @@ -504,7 +525,7 @@ export const Playground = { max="40" step="1" value={subtitleEdge()} - onInput={(event) => setSubtitleEdge(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {subtitleEdge()}% @@ -523,7 +544,7 @@ export const Playground = { max="1400" step="10" value={countDuration()} - onInput={(event) => setCountDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -540,7 +561,7 @@ export const Playground = { max="40" step="1" value={countMask()} - onInput={(event) => setCountMask(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {countMask()}% @@ -555,7 +576,7 @@ export const Playground = { max="14" step="1" value={countMaskHeight()} - onInput={(event) => setCountMaskHeight(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {countMaskHeight()}px @@ -570,7 +591,7 @@ export const Playground = { max="1200" step="10" value={countWidthDuration()} - onInput={(event) => setCountWidthDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx index 4be3a02bbec..cf160b188bf 100644 --- a/packages/ui/src/components/tool-count-summary.stories.tsx +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -1,5 +1,6 @@ // @ts-nocheck -import { createSignal, onCleanup } from "solid-js" +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { AnimatedCountList, type CountItem } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" @@ -57,11 +58,18 @@ const smallBtn = (active?: boolean) => export const Playground = { render: () => { - const [reads, setReads] = createSignal(0) - const [searches, setSearches] = createSignal(0) - const [lists, setLists] = createSignal(0) - const [active, setActive] = createSignal(false) - const [reducedMotion, setReducedMotion] = createSignal(false) + const [state, setState] = createStore({ + reads: 0, + searches: 0, + lists: 0, + active: false, + reducedMotion: false, + }) + const reads = () => state.reads + const searches = () => state.searches + const lists = () => state.lists + const active = () => state.active + const reducedMotion = () => state.reducedMotion let timeouts: ReturnType[] = [] @@ -74,10 +82,10 @@ export const Playground = { const startSim = () => { clearAll() - setReads(0) - setSearches(0) - setLists(0) - setActive(true) + setState("reads", 0) + setState("searches", 0) + setState("lists", 0) + setState("active", true) const steps = rand(3, 10) let elapsed = 0 @@ -86,27 +94,27 @@ export const Playground = { elapsed += delay const t = setTimeout(() => { const pick = rand(0, 2) - if (pick === 0) setReads((n) => n + 1) - else if (pick === 1) setSearches((n) => n + 1) - else setLists((n) => n + 1) + if (pick === 0) setState("reads", (value) => value + 1) + else if (pick === 1) setState("searches", (value) => value + 1) + else setState("lists", (value) => value + 1) }, elapsed) timeouts.push(t) } - const end = setTimeout(() => setActive(false), elapsed + 100) + const end = setTimeout(() => setState("active", false), elapsed + 100) timeouts.push(end) } const stopSim = () => { clearAll() - setActive(false) + setState("active", false) } const reset = () => { stopSim() - setReads(0) - setSearches(0) - setLists(0) + setState("reads", 0) + setState("searches", 0) + setState("lists", 0) } const items = (): CountItem[] => [ @@ -164,19 +172,19 @@ export const Playground = { -
- - -
diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index ba39ae586af..0c99924de98 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -1,4 +1,5 @@ -import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js" +import { type ComponentProps, createMemo, Show, splitProps } from "solid-js" +import { createStore } from "solid-js/store" import { Card, CardDescription } from "./card" import { Collapsible } from "./collapsible" import { Icon } from "./icon" @@ -16,8 +17,12 @@ export interface ToolErrorCardProps extends Omit, "c export function ToolErrorCard(props: ToolErrorCardProps) { const i18n = useI18n() - const [open, setOpen] = createSignal(props.defaultOpen ?? false) - const [copied, setCopied] = createSignal(false) + const [state, setState] = createStore({ + open: props.defaultOpen ?? false, + copied: false, + }) + const open = () => state.open + const copied = () => state.copied const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { const map: Record = { @@ -65,13 +70,18 @@ export function ToolErrorCard(props: ToolErrorCardProps) { const text = cleaned() if (!text) return await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setState("copied", true) + setTimeout(() => setState("copied", false), 2000) } return ( - + setState("open", value)} + >
diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 68440b6c637..2a58e0e5bb6 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,5 @@ -import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" function common(active: string, done: string) { @@ -35,8 +36,12 @@ export function ToolStatusTitle(props: { const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) - const [width, setWidth] = createSignal("auto") - const [ready, setReady] = createSignal(false) + const [state, setState] = createStore({ + width: "auto", + ready: false, + }) + const width = () => state.width + const ready = () => state.ready let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined let frame: number | undefined @@ -45,7 +50,7 @@ export function ToolStatusTitle(props: { const measure = () => { const target = props.active ? activeRef : doneRef const px = contentWidth(target) - if (px > 0) setWidth(`${px}px`) + if (px > 0) setState("width", `${px}px`) } const schedule = () => { @@ -62,13 +67,13 @@ export function ToolStatusTitle(props: { const finish = () => { if (typeof requestAnimationFrame !== "function") { - setReady(true) + setState("ready", true) return } if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) readyFrame = requestAnimationFrame(() => { readyFrame = undefined - setReady(true) + setState("ready", true) }) } diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts index ee608152d5e..692ab31670f 100644 --- a/packages/ui/src/pierre/file-find.ts +++ b/packages/ui/src/pierre/file-find.ts @@ -1,4 +1,5 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" export type FindHost = { element: () => HTMLElement | undefined @@ -107,11 +108,18 @@ export function createFileFind(opts: CreateFileFindOptions) { let mode: "highlights" | "overlay" = "overlay" let hits: Range[] = [] - const [open, setOpen] = createSignal(false) - const [query, setQuery] = createSignal("") - const [index, setIndex] = createSignal(0) - const [count, setCount] = createSignal(0) - const [pos, setPos] = createSignal({ top: 8, right: 8 }) + const [state, setState] = createStore({ + open: false, + query: "", + index: 0, + count: 0, + pos: { top: 8, right: 8 }, + }) + const open = () => state.open + const query = () => state.query + const index = () => state.index + const count = () => state.count + const pos = () => state.pos const clearOverlayScroll = () => { for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay) @@ -200,8 +208,8 @@ export function createFileFind(opts: CreateFileFindOptions) { clearOverlay() clearOverlayScroll() hits = [] - setCount(0) - setIndex(0) + setState("count", 0) + setState("index", 0) } const positionBar = () => { @@ -214,7 +222,7 @@ export function createFileFind(opts: CreateFileFindOptions) { const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) const header = Number.isNaN(title) ? 0 : title - setPos({ + setState("pos", { top: Math.round(rect.top) + header - 4, right: Math.round(window.innerWidth - rect.right) + 8, }) @@ -318,8 +326,8 @@ export function createFileFind(opts: CreateFileFindOptions) { const currentIndex = total ? Math.min(desired, total - 1) : 0 hits = ranges - setCount(total) - setIndex(currentIndex) + setState("count", total) + setState("index", currentIndex) const active = ranges[currentIndex] if (mode === "highlights") { @@ -342,8 +350,8 @@ export function createFileFind(opts: CreateFileFindOptions) { } const close = () => { - setOpen(false) - setQuery("") + setState("open", false) + setState("query", "") clearFind() if (current === host) current = undefined } @@ -352,7 +360,7 @@ export function createFileFind(opts: CreateFileFindOptions) { if (current && current !== host) current.close() current = host target = host - if (!open()) setOpen(true) + if (!open()) setState("open", true) requestAnimationFrame(() => { apply({ scroll: true }) input?.focus() @@ -366,7 +374,7 @@ export function createFileFind(opts: CreateFileFindOptions) { if (total <= 0) return const currentIndex = (index() + dir + total) % total - setIndex(currentIndex) + setState("index", currentIndex) const active = hits[currentIndex] if (!active) return @@ -449,8 +457,8 @@ export function createFileFind(opts: CreateFileFindOptions) { input = el }, setQuery: (value: string) => { - setQuery(value) - setIndex(0) + setState("query", value) + setState("index", 0) apply({ reset: true, scroll: true }) }, focus, From 05cb3c87ca387be41aceb5ccad978c6848a56f70 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:48:38 -0500 Subject: [PATCH 109/357] chore(app): i18n sync (#17283) --- packages/app/src/app.tsx | 15 +++- packages/app/src/components/debug-bar.tsx | 70 +++++++++-------- .../app/src/components/dialog-select-file.tsx | 2 +- .../src/components/dialog-select-server.tsx | 4 +- .../app/src/components/server/server-row.tsx | 4 +- .../src/components/session/session-header.tsx | 54 ++++++------- .../session/session-sortable-terminal-tab.tsx | 7 +- .../app/src/components/settings-keybinds.tsx | 2 +- packages/app/src/components/terminal.tsx | 2 +- packages/app/src/context/command.tsx | 61 ++++++++++++--- packages/app/src/context/file.tsx | 6 +- packages/app/src/context/global-sdk.tsx | 6 +- packages/app/src/context/global-sync.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 2 +- .../context/global-sync/child-store.test.ts | 1 + .../src/context/global-sync/child-store.ts | 9 ++- packages/app/src/context/terminal-title.ts | 51 ++++++++++++ packages/app/src/context/terminal.tsx | 11 +-- packages/app/src/i18n/ar.ts | 73 ++++++++++++++++++ packages/app/src/i18n/br.ts | 75 ++++++++++++++++++ packages/app/src/i18n/bs.ts | 75 ++++++++++++++++++ packages/app/src/i18n/da.ts | 75 ++++++++++++++++++ packages/app/src/i18n/de.ts | 76 ++++++++++++++++++ packages/app/src/i18n/en.ts | 76 ++++++++++++++++++ packages/app/src/i18n/es.ts | 75 ++++++++++++++++++ packages/app/src/i18n/fr.ts | 77 +++++++++++++++++++ packages/app/src/i18n/ja.ts | 74 ++++++++++++++++++ packages/app/src/i18n/ko.ts | 74 ++++++++++++++++++ packages/app/src/i18n/no.ts | 75 ++++++++++++++++++ packages/app/src/i18n/pl.ts | 76 ++++++++++++++++++ packages/app/src/i18n/ru.ts | 75 ++++++++++++++++++ packages/app/src/i18n/th.ts | 75 ++++++++++++++++++ packages/app/src/i18n/tr.ts | 74 ++++++++++++++++++ packages/app/src/i18n/zh.ts | 73 ++++++++++++++++++ packages/app/src/i18n/zht.ts | 73 ++++++++++++++++++ packages/app/src/pages/error.tsx | 32 ++++---- packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/session.tsx | 8 +- .../composer/session-composer-region.tsx | 1 - .../composer/session-question-dock.tsx | 2 +- .../session/composer/session-todo-dock.tsx | 28 +++++-- .../app/src/pages/session/terminal-label.ts | 6 +- packages/ui/src/components/basic-tool.tsx | 5 +- packages/ui/src/components/file-search.tsx | 11 ++- .../components/line-comment-annotations.tsx | 8 +- packages/ui/src/components/message-part.tsx | 20 ++--- .../ui/src/components/tool-error-card.tsx | 16 ++-- packages/ui/src/i18n/ar.ts | 12 +++ packages/ui/src/i18n/br.ts | 12 +++ packages/ui/src/i18n/bs.ts | 12 +++ packages/ui/src/i18n/da.ts | 12 +++ packages/ui/src/i18n/de.ts | 12 +++ packages/ui/src/i18n/en.ts | 13 ++++ packages/ui/src/i18n/es.ts | 12 +++ packages/ui/src/i18n/fr.ts | 12 +++ packages/ui/src/i18n/ja.ts | 12 +++ packages/ui/src/i18n/ko.ts | 12 +++ packages/ui/src/i18n/no.ts | 12 +++ packages/ui/src/i18n/pl.ts | 12 +++ packages/ui/src/i18n/ru.ts | 12 +++ packages/ui/src/i18n/th.ts | 12 +++ packages/ui/src/i18n/tr.ts | 12 +++ packages/ui/src/i18n/zh.ts | 12 +++ packages/ui/src/i18n/zht.ts | 12 +++ packages/ui/src/pierre/selection-bridge.ts | 9 ++- 65 files changed, 1776 insertions(+), 156 deletions(-) create mode 100644 packages/app/src/context/terminal-title.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c6fca36d59a..e370862212b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -12,6 +12,7 @@ import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type Duration, Effect } from "effect" import { type Component, + createMemo, createResource, createSignal, ErrorBoundary, @@ -67,7 +68,7 @@ const SessionIndexRoute = () => function UiI18nBridge(props: ParentProps) { const language = useLanguage() - return {props.children} + return {props.children} } declare global { @@ -218,8 +219,12 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { } function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { + const language = useLanguage() const server = useServer() const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) + const name = createMemo(() => server.name || server.key) + const serverToken = "\u0000server\u0000" + const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) const timer = setInterval(() => props.onRetry?.(), 1000) onCleanup(() => clearInterval(timer)) @@ -229,13 +234,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:

- Could not reach {server.name || server.key} + {unreachable()[0]} + {name()} + {unreachable()[1]}

-

Retrying automatically...

+

{language.t("app.server.retrying")}

0}>
- Other servers + {language.t("app.server.otherServers")}
{(conn) => { diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 6fde71f3b2e..cbb24f77bc1 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -2,6 +2,7 @@ import { useIsRouting, useLocation } from "@solidjs/router" import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useLanguage } from "@/context/language" type Mem = Performance & { memory?: { @@ -27,17 +28,17 @@ type Obs = PerformanceObserverInit & { const span = 5000 const ms = (n?: number, d = 0) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return return `${n.toFixed(d)}ms` } const time = (n?: number) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return return `${Math.round(n)}` } const mb = (n?: number) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return const v = n / 1024 / 1024 return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` } @@ -74,6 +75,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; } export function DebugBar() { + const language = useLanguage() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -98,14 +100,15 @@ export function DebugBar() { }, }) + const na = () => language.t("debugBar.na") const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heapv = () => { const value = heap() - if (value === undefined) return "n/a" + if (value === undefined) return na() return `${Math.round(value * 100)}%` } - const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`) - const navv = () => (state.nav.pending ? "..." : time(state.nav.dur)) + const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) + const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) let prev = "" let start = 0 @@ -359,7 +362,7 @@ export function DebugBar() { return (
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 655aba0b023..eb039c14d61 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -149,7 +149,7 @@ function ServerForm(props: ServerFormProps) { {conn().http.username} ) : ( - no username + {language.t("server.row.noUsername")} )} {conn().http.password && ••••••••} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 8cb704bf1df..ae9d2800ed4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -46,63 +46,63 @@ type OS = "macos" | "windows" | "linux" | "unknown" const MAC_APPS = [ { id: "vscode", - label: "VS Code", + label: "session.header.open.app.vscode", icon: "vscode", openWith: "Visual Studio Code", }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, { id: "antigravity", - label: "Antigravity", + label: "session.header.open.app.antigravity", icon: "antigravity", openWith: "Antigravity", }, - { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "warp", label: "Warp", icon: "warp", openWith: "Warp" }, - { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, { id: "android-studio", - label: "Android Studio", + label: "session.header.open.app.androidStudio", icon: "android-studio", openWith: "Android Studio", }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, ] as const const WINDOWS_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, { id: "powershell", - label: "PowerShell", + label: "session.header.open.app.powershell", icon: "powershell", openWith: "powershell", }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, ] as const const LINUX_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, @@ -160,9 +160,9 @@ export function SessionHeader() { }) const fileManager = createMemo(() => { - if (os() === "macos") return { label: "Finder", icon: "finder" as const } - if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const } - return { label: "File Manager", icon: "finder" as const } + if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } }) createEffect(() => { @@ -187,8 +187,10 @@ export function SessionHeader() { const options = createMemo(() => { return [ - { id: "finder", label: fileManager().label, icon: fileManager().icon }, - ...apps().filter((app) => exists[app.id]), + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), ] as const }) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 4f49911c127..89895874250 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLanguage } from "@/context/language" import { focusTerminalById } from "@/pages/session/helpers" @@ -27,11 +28,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const isDefaultTitle = () => { const number = props.terminal.titleNumber if (!Number.isFinite(number) || number <= 0) return false - const match = props.terminal.title.match(/^Terminal (\d+)$/) - if (!match) return false - const parsed = Number(match[1]) - if (!Number.isFinite(parsed) || parsed <= 0) return false - return parsed === number + return isDefaultTerminalTitle(props.terminal.title, number) } const label = () => { diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 1e42447895d..7e2a48110cb 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -239,7 +239,7 @@ function useKeyCapture(input: { showToast({ title: input.language.t("settings.shortcuts.conflict.title"), description: input.language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next), + keybind: formatKeybind(next, input.language.t), titles: [...conflicts.values()].join(", "), }), }) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 84090329388..ff455ebe205 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -519,7 +519,7 @@ export const Terminal = (props: TerminalProps) => { if (event.code !== 1000) { if (once.value) return once.value = true - local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) + local.onConnectError?.(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } } socket.addEventListener("close", handleClose) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318dab..2c6799d12be 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "sol import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { dict as en } from "@/i18n/en" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { Persist, persisted } from "@/utils/persist" @@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) +type KeyLabel = + | "common.key.ctrl" + | "common.key.alt" + | "common.key.shift" + | "common.key.meta" + | "common.key.space" + | "common.key.backspace" + | "common.key.enter" + | "common.key.tab" + | "common.key.delete" + | "common.key.home" + | "common.key.end" + | "common.key.pageUp" + | "common.key.pageDown" + | "common.key.insert" + | "common.key.esc" + +function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) { + return t ? t(key) : en[key] +} + function actionId(id: string) { if (!id.startsWith(SUGGESTED_PREFIX)) return id return id.slice(SUGGESTED_PREFIX.length) @@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean return false } -export function formatKeybind(config: string): string { +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { if (!config || config === "none") return "" const keybinds = parseKeybind(config) @@ -154,10 +176,10 @@ export function formatKeybind(config: string): string { const kb = keybinds[0] const parts: string[] = [] - if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") - if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") - if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") - if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) + if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t)) + 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 = { @@ -167,10 +189,29 @@ export function formatKeybind(config: string): string { arrowright: "→", comma: ",", plus: "+", - space: "Space", + } + 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] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) + 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) } @@ -364,17 +405,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, keybind(id: string) { if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) + 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) + if (option?.keybind) return formatKeybind(option.keybind, language.t) const meta = catalog[base] const config = bind(base, meta?.keybind) if (!config) return "" - return formatKeybind(config) + return formatKeybind(config, language.t) }, show: showPalette, keybinds(enabled: boolean) { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 99c6d2e4219..f8fec7142d8 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -43,10 +43,10 @@ export { touchFileContent, } -function errorMessage(error: unknown) { +function errorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message if (typeof error === "string" && error) return error - return "Unknown error" + return fallback } export const { use: useFile, provider: FileProvider } = createSimpleContext({ @@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }) .catch((e) => { if (scope() !== directory) return - setLoadError(file, errorMessage(e)) + setLoadError(file, errorMessage(e, language.t("error.chain.unknown"))) }) .finally(() => { inflight.delete(key) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index c1a87b95b89..60e9fd6d542 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" import z from "zod" import { createSdkForServer } from "@/utils/server" +import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -14,6 +15,7 @@ const abortError = z.object({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", init: () => { + const language = useLanguage() const server = useServer() const platform = usePlatform() const abort = new AbortController() @@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo })() const currentServer = server.current - if (!currentServer) throw new Error("No server available") + if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable")) const eventSdk = createSdkForServer({ signal: abort.signal, @@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo event: emitter, createClient(opts: Omit[0], "server" | "fetch">) { const s = server.current - if (!s) throw new Error("Server not available") + if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable")) return createSdkForServer({ server: s.http, fetch: platform.fetch, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 1b6cdf530a7..c8409886928 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -164,6 +164,7 @@ function createGlobalSync() { sdkCache.delete(directory) clearSessionPrefetchDirectory(directory) }, + translate: language.t, }) const sdkFor = (directory: string) => { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 8b1a3c48c5f..13494b7ade0 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: { const project = getFilename(input.directory) showToast({ variant: "error", - title: `Failed to reload ${project}`, + title: input.translate("toast.project.reloadFailed.title", { project }), description: formatServerError(err, input.translate), }) input.setStore("status", "partial") diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index cec76ff87ec..eee763f16de 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -21,6 +21,7 @@ describe("createChildStoreManager", () => { isLoadingSessions: () => false, onBootstrap() {}, onDispose() {}, + translate: (key) => key, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index e2ada244fb3..d5904c60964 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -21,6 +21,7 @@ export function createChildStoreManager(input: { isLoadingSessions: (directory: string) => boolean onBootstrap: (directory: string) => void onDispose: (directory: string) => void + translate: (key: string, vars?: Record) => string }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -129,7 +130,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as VcsInfo | undefined }), ), ) - if (!vcs) throw new Error("Failed to create persisted cache") + if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) @@ -139,7 +140,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as ProjectMeta | undefined }), ), ) - if (!meta) throw new Error("Failed to create persisted project metadata") + if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => @@ -148,7 +149,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as string | undefined }), ), ) - if (!icon) throw new Error("Failed to create persisted project icon") + if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => @@ -211,7 +212,7 @@ export function createChildStoreManager(input: { } mark(directory) const childStore = children[directory] - if (!childStore) throw new Error("Failed to create store") + if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts new file mode 100644 index 00000000000..3e8fa9af251 --- /dev/null +++ b/packages/app/src/context/terminal-title.ts @@ -0,0 +1,51 @@ +import { dict as ar } from "@/i18n/ar" +import { dict as br } from "@/i18n/br" +import { dict as bs } from "@/i18n/bs" +import { dict as da } from "@/i18n/da" +import { dict as de } from "@/i18n/de" +import { dict as en } from "@/i18n/en" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as ja } from "@/i18n/ja" +import { dict as ko } from "@/i18n/ko" +import { dict as no } from "@/i18n/no" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as th } from "@/i18n/th" +import { dict as tr } from "@/i18n/tr" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" + +const numbered = Array.from( + new Set([ + en["terminal.title.numbered"], + ar["terminal.title.numbered"], + br["terminal.title.numbered"], + bs["terminal.title.numbered"], + da["terminal.title.numbered"], + de["terminal.title.numbered"], + es["terminal.title.numbered"], + fr["terminal.title.numbered"], + ja["terminal.title.numbered"], + ko["terminal.title.numbered"], + no["terminal.title.numbered"], + pl["terminal.title.numbered"], + ru["terminal.title.numbered"], + th["terminal.title.numbered"], + tr["terminal.title.numbered"], + zh["terminal.title.numbered"], + zht["terminal.title.numbered"], + ]), +) + +export function defaultTitle(number: number) { + return en["terminal.title.numbered"].replace("{{number}}", String(number)) +} + +export function isDefaultTitle(title: string, number: number) { + return numbered.some((text) => title === text.replace("{{number}}", String(number))) +} + +export function titleNumber(title: string, max: number) { + return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number)) +} diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a2807375ff6..e65c1678846 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" +import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" export type LocalPTY = { @@ -33,11 +34,7 @@ function num(value: unknown) { } function numberFromTitle(title: string) { - const match = title.match(/^Terminal (\d+)$/) - if (!match) return - const value = Number(match[1]) - if (!Number.isFinite(value) || value <= 0) return - return value + return titleNumber(title, MAX_TERMINAL_SESSIONS) } function pty(value: unknown): LocalPTY | undefined { @@ -202,13 +199,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str const nextNumber = pickNextTerminalNumber() sdk.client.pty - .create({ title: `Terminal ${nextNumber}` }) + .create({ title: defaultTitle(nextNumber) }) .then((pty: { data?: { id?: string; title?: string } }) => { const id = pty.data?.id if (!id) return const newTerminal = { id, - title: pty.data?.title ?? "Terminal", + title: pty.data?.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } setStore("all", store.all.length, newTerminal) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 1b872082679..99a2d03d094 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -778,4 +778,77 @@ export const dict = { "common.time.daysAgo.short": "قبل {{count}} ي", "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك", "settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.", + + "app.server.unreachable": "تعذر الوصول إلى {{server}}", + "app.server.retrying": "جاري إعادة المحاولة تلقائيًا...", + "app.server.otherServers": "خوادم أخرى", + "dialog.server.add.usernamePlaceholder": "اسم المستخدم", + "dialog.server.add.passwordPlaceholder": "كلمة المرور", + "server.row.noUsername": "لا يوجد اسم مستخدم", + "session.review.noVcs.createGit.title": "إنشاء مستودع Git", + "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع", + "session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...", + "session.review.noVcs.createGit.action": "إنشاء مستودع Git", + "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام", + "session.question.progress": "{{current}} من {{total}} أسئلة", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "مستكشف الملفات", + "session.header.open.fileManager": "مدير الملفات", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "المحطة الطرفية", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "تشخيص أداء التطوير", + "debugBar.na": "غير متاح", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.", + "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "غير معروف", + "error.page.circular": "[دائري]", + "error.globalSDK.noServerAvailable": "لا يوجد خادم متاح", + "error.globalSDK.serverNotAvailable": "الخادم غير متاح", + "error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة", + "error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة", + "error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة", + "error.childStore.storeCreateFailed": "فشل إنشاء المخزن", + "terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}", } diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 9e1f7f2a0e8..46ee7f114e1 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -788,4 +788,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}}d atrás", "settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente", "settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.", + + "app.server.unreachable": "Não foi possível conectar a {{server}}", + "app.server.retrying": "Tentando novamente automaticamente...", + "app.server.otherServers": "Outros servidores", + "dialog.server.add.usernamePlaceholder": "nome de usuário", + "dialog.server.add.passwordPlaceholder": "senha", + "server.row.noUsername": "sem nome de usuário", + "session.review.noVcs.createGit.title": "Criar um repositório Git", + "session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto", + "session.review.noVcs.createGit.actionLoading": "Criando repositório Git...", + "session.review.noVcs.createGit.action": "Criar repositório Git", + "session.todo.progress": "{{done}} de {{total}} tarefas concluídas", + "session.question.progress": "{{current}} de {{total}} perguntas", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorador de Arquivos", + "session.header.open.fileManager": "Gerenciador de Arquivos", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.", + "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Espaço", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "desconhecido", + "error.page.circular": "[Circular]", + "error.globalSDK.noServerAvailable": "Nenhum servidor disponível", + "error.globalSDK.serverNotAvailable": "Servidor indisponível", + "error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente", + "error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes", + "error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente", + "error.childStore.storeCreateFailed": "Falha ao criar armazenamento", + "terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}", } diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 3151c9b2213..140b838103b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -864,4 +864,79 @@ export const dict = { "common.time.daysAgo.short": "prije {{count}} d", "settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja", "settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.", + + "app.server.unreachable": "Nije moguće pristupiti {{server}}", + "app.server.retrying": "Automatski ponovni pokušaj...", + "app.server.otherServers": "Drugi serveri", + "dialog.server.add.usernamePlaceholder": "korisničko ime", + "dialog.server.add.passwordPlaceholder": "lozinka", + "server.row.noUsername": "nema korisničkog imena", + "session.review.noVcs.createGit.title": "Kreiraj Git repozitorij", + "session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu", + "session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...", + "session.review.noVcs.createGit.action": "Kreiraj Git repozitorij", + "session.todo.progress": "{{done}} od {{total}} zadataka završeno", + "session.question.progress": "{{current}} od {{total}} pitanja", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Dijagnostika performansi razvoja", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.", + "debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "nepoznato", + "error.page.circular": "[Kružno]", + "error.globalSDK.noServerAvailable": "Nema dostupnog servera", + "error.globalSDK.serverNotAvailable": "Server nije dostupan", + "error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša", + "error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta", + "error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta", + "error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta", + "terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}", } diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 8d9331ab68f..9b776c143e2 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -858,4 +858,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}}d siden", "settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler", "settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.", + + "app.server.unreachable": "Kunne ikke nå {{server}}", + "app.server.retrying": "Prøver igen automatisk...", + "app.server.otherServers": "Andre servere", + "dialog.server.add.usernamePlaceholder": "brugernavn", + "dialog.server.add.passwordPlaceholder": "adgangskode", + "server.row.noUsername": "intet brugernavn", + "session.review.noVcs.createGit.title": "Opret et Git-repository", + "session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt", + "session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...", + "session.review.noVcs.createGit.action": "Opret Git-repository", + "session.todo.progress": "{{done}} af {{total}} opgaver fuldført", + "session.question.progress": "{{current}} af {{total}} spørgsmål", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Stifinder", + "session.header.open.fileManager": "Filhåndtering", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Udviklingsydelsesdiagnostik", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.", + "debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Mellemrum", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ukendt", + "error.page.circular": "[Cirkulær]", + "error.globalSDK.noServerAvailable": "Ingen server tilgængelig", + "error.globalSDK.serverNotAvailable": "Server ikke tilgængelig", + "error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache", + "error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata", + "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon", + "error.childStore.storeCreateFailed": "Kunne ikke oprette lager", + "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}", } diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 782b67262fc..5031748b46c 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -799,4 +799,80 @@ export const dict = { "common.time.daysAgo.short": "vor {{count}} Tg", "settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen", "settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.", + + "app.server.unreachable": "Konnte {{server}} nicht erreichen", + "app.server.retrying": "Automatische erneute Verbindung...", + "app.server.otherServers": "Andere Server", + "dialog.server.add.usernamePlaceholder": "Benutzername", + "dialog.server.add.passwordPlaceholder": "Passwort", + "server.row.noUsername": "Kein Benutzername", + "session.review.noVcs.createGit.title": "Git-Repository erstellen", + "session.review.noVcs.createGit.description": + "Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen", + "session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...", + "session.review.noVcs.createGit.action": "Git-Repository erstellen", + "session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt", + "session.question.progress": "{{current}} von {{total}} Fragen", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Datei-Explorer", + "session.header.open.fileManager": "Dateimanager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Entwicklungs-Leistungsdiagnose", + "debugBar.na": "n.v.", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Letzter abgeschlossener Routenübergang, der eine Sitzungsseite berührt, gemessen vom Start des Routers bis zum ersten Rendern nach dem Einschwingen.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Gleitende Bilder pro Sekunde in den letzten 5 Sekunden.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Schlechteste Frame-Zeit in den letzten 5 Sekunden.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Frames über 32ms in den letzten 5 Sekunden.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blockierte Zeit und Anzahl langer Aufgaben in den letzten 5 Sekunden. Max Aufgabe: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Schlechteste beobachtete Eingabeverzögerung in den letzten 5 Sekunden.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Ungefähre Interaktionsdauer in den letzten 5 Sekunden. Dies ist INP-ähnlich, nicht das offizielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulative Layoutverschiebung für die aktuelle App-Lebensdauer.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Verwendeter JS-Heap vs Heap-Limit. Nur Chromium.", + "debugBar.mem.tip": "Verwendeter JS-Heap vs Heap-Limit. {{used}} von {{limit}}.", + "common.key.ctrl": "Strg", + "common.key.alt": "Alt", + "common.key.shift": "Umschalt", + "common.key.meta": "Meta", + "common.key.space": "Leertaste", + "common.key.backspace": "Rücktaste", + "common.key.enter": "Eingabe", + "common.key.tab": "Tab", + "common.key.delete": "Entf", + "common.key.home": "Pos1", + "common.key.end": "Ende", + "common.key.pageUp": "Bild auf", + "common.key.pageDown": "Bild ab", + "common.key.insert": "Einfg", + "common.unknown": "unbekannt", + "error.page.circular": "[Zirkulär]", + "error.globalSDK.noServerAvailable": "Kein Server verfügbar", + "error.globalSDK.serverNotAvailable": "Server nicht verfügbar", + "error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden", + "error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden", + "error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden", + "error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden", + "terminal.connectionLost.abnormalClose": "WebSocket abnormal geschlossen: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b950cab8d64..65e878b4e9b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -306,6 +306,10 @@ export const dict = { "dialog.directory.search.placeholder": "Search folders", "dialog.directory.empty": "No folders found", + "app.server.unreachable": "Could not reach {{server}}", + "app.server.retrying": "Retrying automatically...", + "app.server.otherServers": "Other servers", + "dialog.server.title": "Servers", "dialog.server.description": "Switch which OpenCode server this app connects to.", "dialog.server.search.placeholder": "Search servers", @@ -319,7 +323,9 @@ export const dict = { "dialog.server.add.name": "Server name (optional)", "dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.username": "Username (optional)", + "dialog.server.add.usernamePlaceholder": "username", "dialog.server.add.password": "Password (optional)", + "dialog.server.add.passwordPlaceholder": "password", "dialog.server.edit.title": "Edit server", "dialog.server.default.title": "Default server", "dialog.server.default.description": @@ -335,6 +341,7 @@ export const dict = { "dialog.server.menu.delete": "Delete", "dialog.server.current": "Current Server", "dialog.server.status.default": "Default", + "server.row.noUsername": "no username", "dialog.project.edit.title": "Edit project", "dialog.project.edit.name": "Name", @@ -456,6 +463,7 @@ export const dict = { "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", + "error.page.circular": "[Circular]", "error.page.report.prefix": "Please report this error to the OpenCode team", "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", @@ -464,6 +472,12 @@ export const dict = { "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", + "error.globalSDK.noServerAvailable": "No server available", + "error.globalSDK.serverNotAvailable": "Server not available", + "error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache", + "error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata", + "error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon", + "error.childStore.storeCreateFailed": "Failed to create store", "directory.error.invalidUrl": "Invalid directory in URL.", "error.chain.unknown": "Unknown error", @@ -512,6 +526,10 @@ export const dict = { "session.review.loadingChanges": "Loading changes...", "session.review.empty": "No changes in this session yet", "session.review.noVcs": "No Git Version Control System detected, changes not displayed", + "session.review.noVcs.createGit.title": "Create a Git repository", + "session.review.noVcs.createGit.description": "Track, review, and undo changes in this project", + "session.review.noVcs.createGit.actionLoading": "Creating Git repository...", + "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", @@ -530,6 +548,8 @@ export const dict = { "session.todo.title": "Todos", "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.todo.progress": "{{done}} of {{total}} todos completed", + "session.question.progress": "{{current}} of {{total}} questions", "session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.sendNow": "Send now", @@ -555,6 +575,22 @@ export const dict = { "session.header.open.ariaLabel": "Open in {{app}}", "session.header.open.menu": "Open options", "session.header.open.copyPath": "Copy path", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", "status.popover.trigger": "Status", "status.popover.ariaLabel": "Server configurations", @@ -587,6 +623,7 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Close terminal", "terminal.connectionLost.title": "Connection Lost", + "terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}", "terminal.connectionLost.description": "The terminal connection was interrupted. This can happen when the server restarts.", @@ -604,6 +641,21 @@ export const dict = { "common.edit": "Edit", "common.loadMore": "Load more", "common.key.esc": "ESC", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "unknown", "common.time.justNow": "Just now", "common.time.minutesAgo.short": "{{count}}m ago", @@ -623,6 +675,30 @@ export const dict = { "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "debugBar.ariaLabel": "Development performance diagnostics", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Last completed route transition touching a session page, measured from router start until the first paint after it settles.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rolling frames per second over the last 5 seconds.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Worst frame time over the last 5 seconds.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Frames over 32ms in the last 5 seconds.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blocked time and long-task count in the last 5 seconds. Max task: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Worst observed input delay in the last 5 seconds.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Cumulative layout shift for the current app lifetime.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", + "debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a2633004cf7..2fabd6d4c8e 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -871,4 +871,79 @@ export const dict = { "common.time.daysAgo.short": "hace {{count}} d", "settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno", "settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.", + + "app.server.unreachable": "No se pudo conectar con {{server}}", + "app.server.retrying": "Reintentando automáticamente...", + "app.server.otherServers": "Otros servidores", + "dialog.server.add.usernamePlaceholder": "usuario", + "dialog.server.add.passwordPlaceholder": "contraseña", + "server.row.noUsername": "sin usuario", + "session.review.noVcs.createGit.title": "Crear repositorio Git", + "session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto", + "session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...", + "session.review.noVcs.createGit.action": "Crear repositorio Git", + "session.todo.progress": "{{done}} de {{total}} tareas completadas", + "session.question.progress": "{{current}} de {{total}} preguntas", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorador de archivos", + "session.header.open.fileManager": "Gestor de archivos", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnóstico de rendimiento de desarrollo", + "debugBar.na": "n/d", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Última transición de ruta completada tocando una página de sesión, medida desde el inicio del router hasta el primer pintado después de asentarse.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Cuadros por segundo en los últimos 5 segundos.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Peor tiempo de cuadro en los últimos 5 segundos.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Cuadros superiores a 32ms en los últimos 5 segundos.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Tiempo bloqueado y recuento de tareas largas en los últimos 5 segundos. Tarea máx: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Peor retraso de entrada observado en los últimos 5 segundos.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Duración aproximada de la interacción en los últimos 5 segundos. Esto es similar a INP, no el INP oficial de Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Cambio de diseño acumulativo para la vida útil actual de la aplicación.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Heap JS usado vs límite de heap. Solo Chromium.", + "debugBar.mem.tip": "Heap JS usado vs límite de heap. {{used}} de {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Mayús", + "common.key.meta": "Meta", + "common.key.space": "Espacio", + "common.key.backspace": "Retroceso", + "common.key.enter": "Intro", + "common.key.tab": "Tab", + "common.key.delete": "Supr", + "common.key.home": "Inicio", + "common.key.end": "Fin", + "common.key.pageUp": "RePág", + "common.key.pageDown": "AvPág", + "common.key.insert": "Insert", + "common.unknown": "desconocido", + "error.page.circular": "[Circular]", + "error.globalSDK.noServerAvailable": "Ningún servidor disponible", + "error.globalSDK.serverNotAvailable": "Servidor no disponible", + "error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente", + "error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes", + "error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente", + "error.childStore.storeCreateFailed": "Error al crear almacén", + "terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}", } diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index cbde19316dd..dc30a0e537a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -796,4 +796,81 @@ export const dict = { "common.time.daysAgo.short": "il y a {{count}}j", "settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement", "settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.", + + "app.server.unreachable": "Impossible de joindre {{server}}", + "app.server.retrying": "Nouvelle tentative automatique...", + "app.server.otherServers": "Autres serveurs", + "dialog.server.add.usernamePlaceholder": "nom d'utilisateur", + "dialog.server.add.passwordPlaceholder": "mot de passe", + "server.row.noUsername": "aucun nom d'utilisateur", + "session.review.noVcs.createGit.title": "Créer un dépôt Git", + "session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet", + "session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...", + "session.review.noVcs.createGit.action": "Créer un dépôt Git", + "session.todo.progress": "{{done}} tâches sur {{total}} terminées", + "session.question.progress": "{{current}} questions sur {{total}}", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorateur de fichiers", + "session.header.open.fileManager": "Gestionnaire de fichiers", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnostics de performance de développement", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Dernière transition de route terminée touchant une page de session, mesurée du début du routeur jusqu'au premier affichage après stabilisation.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Images par seconde glissantes sur les 5 dernières secondes.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Pire temps d'image sur les 5 dernières secondes.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Images de plus de 32ms au cours des 5 dernières secondes.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": + "Temps bloqué et nombre de tâches longues au cours des 5 dernières secondes. Tâche max : {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Pire délai d'entrée observé au cours des 5 dernières secondes.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Durée approximative d'interaction au cours des 5 dernières secondes. Ceci est similaire à INP, pas le INP officiel des Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Décalage cumulatif de la mise en page pour la durée de vie actuelle de l'application.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Tas JS utilisé vs limite de tas. Chromium uniquement.", + "debugBar.mem.tip": "Tas JS utilisé vs limite de tas. {{used}} sur {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Maj", + "common.key.meta": "Méta", + "common.key.space": "Espace", + "common.key.backspace": "Retour arrière", + "common.key.enter": "Entrée", + "common.key.tab": "Tab", + "common.key.delete": "Suppr", + "common.key.home": "Début", + "common.key.end": "Fin", + "common.key.pageUp": "Page précédente", + "common.key.pageDown": "Page suivante", + "common.key.insert": "Inser", + "common.unknown": "inconnu", + "error.page.circular": "[Circulaire]", + "error.globalSDK.noServerAvailable": "Aucun serveur disponible", + "error.globalSDK.serverNotAvailable": "Serveur non disponible", + "error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant", + "error.childStore.persistedProjectMetadataCreateFailed": + "Échec de la création des métadonnées de projet persistantes", + "error.childStore.persistedProjectIconCreateFailed": "Échec de la création de l'icône de projet persistante", + "error.childStore.storeCreateFailed": "Échec de la création du stockage", + "terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}", } diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 914ac5cd796..1f5615c9bd1 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -783,4 +783,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}} 日前", "settings.providers.connected.environmentDescription": "環境変数から接続されました", "settings.providers.custom.description": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。", + + "app.server.unreachable": "{{server}} に到達できませんでした", + "app.server.retrying": "自動的に再試行中...", + "app.server.otherServers": "その他のサーバー", + "dialog.server.add.usernamePlaceholder": "ユーザー名", + "dialog.server.add.passwordPlaceholder": "パスワード", + "server.row.noUsername": "ユーザー名なし", + "session.review.noVcs.createGit.title": "Git リポジトリを作成", + "session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す", + "session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...", + "session.review.noVcs.createGit.action": "Git リポジトリを作成", + "session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了", + "session.question.progress": "{{total}} 問中 {{current}} 問", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "エクスプローラー", + "session.header.open.fileManager": "ファイルマネージャー", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "ターミナル", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "開発パフォーマンス診断", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "セッションページに触れる最後に完了したルート遷移。ルーター開始から安定後の最初の描画まで測定。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "過去5秒間のローリングフレーム/秒。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "過去5秒間の最悪フレーム時間。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "過去5秒間で32msを超えたフレーム。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "過去5秒間のブロック時間と長時間タスク数。最大タスク: {{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "過去5秒間で観測された最悪の入力遅延。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "過去5秒間の概算インタラクション時間。これは INP に似ていますが、公式の Web Vitals INP ではありません。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "現在のアプリ寿命の累積レイアウトシフト。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用中の JS ヒープ対ヒープ制限。Chromium のみ。", + "debugBar.mem.tip": "使用中の JS ヒープ対ヒープ制限。{{limit}} 中 {{used}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "不明", + "error.page.circular": "[循環]", + "error.globalSDK.noServerAvailable": "利用可能なサーバーがありません", + "error.globalSDK.serverNotAvailable": "サーバーが利用できません", + "error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました", + "error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました", + "error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました", + "error.childStore.storeCreateFailed": "ストアの作成に失敗しました", + "terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}", } diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index f0a3f3ae6b2..a2f5e5c7c8b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -782,4 +782,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}}일 전", "settings.providers.connected.environmentDescription": "환경 변수에서 연결됨", "settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.", + + "app.server.unreachable": "{{server}}에 연결할 수 없습니다", + "app.server.retrying": "자동으로 재시도 중...", + "app.server.otherServers": "다른 서버", + "dialog.server.add.usernamePlaceholder": "사용자 이름", + "dialog.server.add.passwordPlaceholder": "비밀번호", + "server.row.noUsername": "사용자 이름 없음", + "session.review.noVcs.createGit.title": "Git 저장소 생성", + "session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소", + "session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...", + "session.review.noVcs.createGit.action": "Git 저장소 생성", + "session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료", + "session.question.progress": "{{total}}개의 질문 중 {{current}}개", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "파일 탐색기", + "session.header.open.fileManager": "파일 관리자", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "터미널", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "개발 성능 진단", + "debugBar.na": "해당 없음", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "세션 페이지에 닿은 마지막 완료된 라우트 전환. 라우터 시작부터 정착 후 첫 번째 페인트까지 측정됨.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "지난 5초간의 초당 프레임 수.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "지난 5초간의 최악의 프레임 시간.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "지난 5초간 32ms를 초과한 프레임.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "지난 5초간의 차단된 시간 및 긴 작업 수. 최대 작업: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "지난 5초간 관찰된 최악의 입력 지연.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "지난 5초간의 대략적인 상호작용 지속 시간. 이것은 공식 Web Vitals INP가 아닌 INP와 유사합니다.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "현재 앱 수명 동안의 누적 레이아웃 이동.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "사용된 JS 힙 대 힙 제한. Chromium 전용.", + "debugBar.mem.tip": "사용된 JS 힙 대 힙 제한. {{limit}} 중 {{used}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "알 수 없음", + "error.page.circular": "[순환]", + "error.globalSDK.noServerAvailable": "사용 가능한 서버 없음", + "error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음", + "error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패", + "error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패", + "error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패", + "error.childStore.storeCreateFailed": "저장소 생성 실패", + "terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}", } diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 17ba96058b5..ed75e556ea6 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -865,4 +865,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} d siden", "settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine", "settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.", + + "app.server.unreachable": "Kunne ikke nå {{server}}", + "app.server.retrying": "Prøver på nytt automatisk...", + "app.server.otherServers": "Andre servere", + "dialog.server.add.usernamePlaceholder": "brukernavn", + "dialog.server.add.passwordPlaceholder": "passord", + "server.row.noUsername": "inget brukernavn", + "session.review.noVcs.createGit.title": "Opprett et Git-depot", + "session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet", + "session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...", + "session.review.noVcs.createGit.action": "Opprett Git-depot", + "session.todo.progress": "{{done}} av {{total}} oppgaver fullført", + "session.question.progress": "{{current}} av {{total}} spørsmål", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Filutforsker", + "session.header.open.fileManager": "Filbehandler", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Utviklingsytelsesdiagnostikk", + "debugBar.na": "i/t", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Siste fullførte ruteovergang som berører en sesjonsside, målt fra ruterstart til første opptegning etter at den har roet seg.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rullende bilder per sekund over de siste 5 sekundene.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Verste bildetid over de siste 5 sekundene.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Bilder over 32ms i de siste 5 sekundene.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokkert tid og antall lange oppgaver i de siste 5 sekundene. Maks oppgave: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Verste observerte inndataforsinkelse i de siste 5 sekundene.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Omtrentlig interaksjonsvarighet over de siste 5 sekundene. Dette er INP-lignende, ikke den offisielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativ layoutforskyvning for gjeldende app-levetid.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Brukt JS-heap vs heap-grense. Kun Chromium.", + "debugBar.mem.tip": "Brukt JS-heap vs heap-grense. {{used}} av {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Mellomrom", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ukjent", + "error.page.circular": "[Sirkulær]", + "error.globalSDK.noServerAvailable": "Ingen server tilgjengelig", + "error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig", + "error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer", + "error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata", + "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon", + "error.childStore.storeCreateFailed": "Kunne ikke opprette lager", + "terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 38111c8738b..2507acd9d2a 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -785,4 +785,80 @@ export const dict = { "common.time.daysAgo.short": "{{count}} dni temu", "settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi", "settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.", + + "app.server.unreachable": "Nie można połączyć z {{server}}", + "app.server.retrying": "Ponawianie automatycznie...", + "app.server.otherServers": "Inne serwery", + "dialog.server.add.usernamePlaceholder": "nazwa użytkownika", + "dialog.server.add.passwordPlaceholder": "hasło", + "server.row.noUsername": "brak nazwy użytkownika", + "session.review.noVcs.createGit.title": "Utwórz repozytorium Git", + "session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie", + "session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...", + "session.review.noVcs.createGit.action": "Utwórz repozytorium Git", + "session.todo.progress": "Ukończono {{done}} z {{total}} zadań", + "session.question.progress": "{{current}} z {{total}} pytań", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Eksplorator plików", + "session.header.open.fileManager": "Menedżer plików", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnostyka wydajności deweloperskiej", + "debugBar.na": "n.d.", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Ostatnie zakończone przejście trasy dotykające strony sesji, mierzone od startu routera do pierwszego odrysowania po ustaleniu.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Średnia liczba klatek na sekundę w ciągu ostatnich 5 sekund.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Najgorszy czas klatki w ciągu ostatnich 5 sekund.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Klatki powyżej 32ms w ciągu ostatnich 5 sekund.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": + "Zablokowany czas i liczba długich zadań w ciągu ostatnich 5 sekund. Maksymalne zadanie: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Najgorsze zaobserwowane opóźnienie wejścia w ciągu ostatnich 5 sekund.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Przybliżony czas trwania interakcji w ciągu ostatnich 5 sekund. Jest to podobne do INP, a nie oficjalne Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Skumulowane przesunięcie układu dla bieżącego czasu życia aplikacji.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Użyta sterta JS vs limit sterty. Tylko Chromium.", + "debugBar.mem.tip": "Użyta sterta JS vs limit sterty. {{used}} z {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Spacja", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "nieznany", + "error.page.circular": "[Cykliczne]", + "error.globalSDK.noServerAvailable": "Brak dostępnego serwera", + "error.globalSDK.serverNotAvailable": "Serwer niedostępny", + "error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej", + "error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu", + "error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu", + "error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu", + "terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}", } diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0b63e842273..6145b3011b3 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -867,4 +867,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} д назад", "settings.providers.connected.environmentDescription": "Подключено из ваших переменных окружения", "settings.providers.custom.description": "Добавить провайдера, совместимого с OpenAI, по базовому URL.", + + "app.server.unreachable": "Не удалось связаться с {{server}}", + "app.server.retrying": "Автоматическая повторная попытка...", + "app.server.otherServers": "Другие серверы", + "dialog.server.add.usernamePlaceholder": "имя пользователя", + "dialog.server.add.passwordPlaceholder": "пароль", + "server.row.noUsername": "нет имени пользователя", + "session.review.noVcs.createGit.title": "Создать репозиторий Git", + "session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте", + "session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...", + "session.review.noVcs.createGit.action": "Создать репозиторий Git", + "session.todo.progress": "Выполнено {{done}} из {{total}} задач", + "session.question.progress": "{{current}} из {{total}} вопросов", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Проводник", + "session.header.open.fileManager": "Файловый менеджер", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Терминал", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Диагностика производительности разработки", + "debugBar.na": "н/д", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Последний завершенный переход маршрута, затрагивающий страницу сеанса, измеренный от запуска маршрутизатора до первой отрисовки после стабилизации.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Скользящая частота кадров в секунду за последние 5 секунд.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Худшее время кадра за последние 5 секунд.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Кадры более 32 мс за последние 5 секунд.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Заблокированное время и количество длинных задач за последние 5 секунд. Макс. задача: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Худшая наблюдаемая задержка ввода за последние 5 секунд.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Приблизительная продолжительность взаимодействия за последние 5 секунд. Это похоже на INP, а не официальный Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Кумулятивный сдвиг макета за текущее время жизни приложения.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Используемая куча JS по сравнению с лимитом кучи. Только Chromium.", + "debugBar.mem.tip": "Используемая куча JS по сравнению с лимитом кучи. {{used}} из {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Пробел", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "неизвестно", + "error.page.circular": "[Циклично]", + "error.globalSDK.noServerAvailable": "Нет доступного сервера", + "error.globalSDK.serverNotAvailable": "Сервер недоступен", + "error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш", + "error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта", + "error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта", + "error.childStore.storeCreateFailed": "Не удалось создать хранилище", + "terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}", } diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 94bb2c2c318..9cc3c5be1da 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -854,4 +854,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} วันที่แล้ว", "settings.providers.connected.environmentDescription": "เชื่อมต่อจากตัวแปรสภาพแวดล้อมของคุณ", "settings.providers.custom.description": "เพิ่มผู้ให้บริการที่รองรับ OpenAI ด้วย URL หลัก", + + "app.server.unreachable": "ไม่สามารถติดต่อ {{server}}", + "app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...", + "app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ", + "dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้", + "dialog.server.add.passwordPlaceholder": "รหัสผ่าน", + "server.row.noUsername": "ไม่มีชื่อผู้ใช้", + "session.review.noVcs.createGit.title": "สร้าง Git repository", + "session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้", + "session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...", + "session.review.noVcs.createGit.action": "สร้าง Git repository", + "session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ", + "session.question.progress": "{{current}} จาก {{total}} คำถาม", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "การวินิจฉัยประสิทธิภาพการพัฒนา", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "การเปลี่ยนเส้นทางที่เสร็จสมบูรณ์ล่าสุดที่สัมผัสหน้าเซสชัน วัดจากจุดเริ่มต้นเราเตอร์จนถึงการวาดครั้งแรกหลังจากที่นิ่ง", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "เฟรมต่อวินาทีแบบต่อเนื่องในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "เวลาเฟรมที่แย่ที่สุดในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "เฟรมที่เกิน 32ms ในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "เวลาที่ถูกบล็อกและจำนวนงานยาวในช่วง 5 วินาทีที่ผ่านมา งานสูงสุด: {{max}}", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "ความล่าช้าในการป้อนข้อมูลที่แย่ที่สุดที่สังเกตได้ในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "ระยะเวลาการโต้ตอบโดยประมาณในช่วง 5 วินาทีที่ผ่านมา นี่เป็นเหมือน INP ไม่ใช่ Web Vitals INP อย่างเป็นทางการ", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "การเลื่อนเลย์เอาต์สะสมสำหรับอายุการใช้งานของแอปปัจจุบัน", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "JS heap ที่ใช้เทียบกับขีดจำกัด heap เฉพาะ Chromium", + "debugBar.mem.tip": "JS heap ที่ใช้เทียบกับขีดจำกัด heap {{used}} จาก {{limit}}", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ไม่ทราบ", + "error.page.circular": "[วงกลม]", + "error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์", + "error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน", + "error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร", + "error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร", + "error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร", + "error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ", + "terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}", } diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 66835c1c574..373f26ad6fb 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -874,4 +874,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}}g önce", "settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı", "settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.", + + "app.server.unreachable": "{{server}} sunucusuna ulaşılamadı", + "app.server.retrying": "Otomatik olarak tekrar deneniyor...", + "app.server.otherServers": "Diğer sunucular", + "dialog.server.add.usernamePlaceholder": "kullanıcı adı", + "dialog.server.add.passwordPlaceholder": "parola", + "server.row.noUsername": "kullanıcı adı yok", + "session.review.noVcs.createGit.title": "Git deposu oluştur", + "session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al", + "session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...", + "session.review.noVcs.createGit.action": "Git deposu oluştur", + "session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı", + "session.question.progress": "{{total}} sorunun {{current}} tanesi", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Dosya Gezgini", + "session.header.open.fileManager": "Dosya Yöneticisi", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Geliştirme performansı teşhisi", + "debugBar.na": "yok", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Yönlendirici başlangıcından yerleşme sonrası ilk boyamaya kadar ölçülen, bir oturum sayfasına dokunan son tamamlanmış rota geçişi.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Son 5 saniyedeki kayan saniye başına kare sayısı.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Son 5 saniyedeki en kötü kare süresi.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Son 5 saniyede 32ms üzerindeki kareler.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Son 5 saniyedeki engellenen süre ve uzun görev sayısı. Maksimum görev: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Son 5 saniyede gözlemlenen en kötü giriş gecikmesi.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "Son 5 saniyedeki yaklaşık etkileşim süresi. Bu INP benzeridir, resmi Web Vitals INP değildir.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Mevcut uygulama ömrü için kümülatif düzen kayması.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Kullanılan JS yığını vs yığın sınırı. Yalnızca Chromium.", + "debugBar.mem.tip": "Kullanılan JS yığını vs yığın sınırı. {{limit}} içinde {{used}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Boşluk", + "common.key.backspace": "Geri", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "bilinmiyor", + "error.page.circular": "[Döngüsel]", + "error.globalSDK.noServerAvailable": "Sunucu yok", + "error.globalSDK.serverNotAvailable": "Sunucu mevcut değil", + "error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı", + "error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı", + "error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı", + "error.childStore.storeCreateFailed": "Depo oluşturulamadı", + "terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d6b179c5c50..819e1cd87d7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -853,4 +853,77 @@ export const dict = { "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已通过环境变量连接", "settings.providers.custom.description": "通过基础 URL 添加与 OpenAI 兼容的提供商。", + + "app.server.unreachable": "无法连接到 {{server}}", + "app.server.retrying": "正在自动重试...", + "app.server.otherServers": "其他服务器", + "dialog.server.add.usernamePlaceholder": "用户名", + "dialog.server.add.passwordPlaceholder": "密码", + "server.row.noUsername": "无用户名", + "session.review.noVcs.createGit.title": "创建 Git 仓库", + "session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改", + "session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...", + "session.review.noVcs.createGit.action": "创建 Git 仓库", + "session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)", + "session.question.progress": "{{current}}/{{total}} 个问题", + "session.header.open.finder": "访达", + "session.header.open.fileExplorer": "文件资源管理器", + "session.header.open.fileManager": "文件管理器", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "终端", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "开发性能诊断", + "debugBar.na": "不适用", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "最后一次完成的涉及会话页面的路由转换,从路由器启动到稳定后的第一次绘制。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "过去 5 秒内的滚动帧率。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "过去 5 秒内最差的帧时间。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "过去 5 秒内超过 32ms 的帧。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "过去 5 秒内的阻塞时间和长任务计数。最大任务:{{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "过去 5 秒内观察到的最差输入延迟。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "过去 5 秒内的近似交互持续时间。这类似于 INP,而非官方的 Web Vitals INP。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "当前应用生命周期的累积布局偏移。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用的 JS 堆与堆限制。仅限 Chromium。", + "debugBar.mem.tip": "使用的 JS 堆与堆限制。{{used}} / {{limit}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "空格", + "common.key.backspace": "退格", + "common.key.enter": "回车", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "未知", + "error.page.circular": "[循环]", + "error.globalSDK.noServerAvailable": "无可用服务器", + "error.globalSDK.serverNotAvailable": "服务器不可用", + "error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败", + "error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败", + "error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败", + "error.childStore.storeCreateFailed": "创建存储失败", + "terminal.connectionLost.abnormalClose": "WebSocket 异常关闭:{{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 3796350d2c4..8c80cd3235e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -848,4 +848,77 @@ export const dict = { "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已從環境變數連線", "settings.providers.custom.description": "透過基本 URL 新增與 OpenAI 相容的提供者。", + + "app.server.unreachable": "無法連線至 {{server}}", + "app.server.retrying": "正在自動重試...", + "app.server.otherServers": "其他伺服器", + "dialog.server.add.usernamePlaceholder": "使用者名稱", + "dialog.server.add.passwordPlaceholder": "密碼", + "server.row.noUsername": "無使用者名稱", + "session.review.noVcs.createGit.title": "建立 Git 儲存庫", + "session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更", + "session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...", + "session.review.noVcs.createGit.action": "建立 Git 儲存庫", + "session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)", + "session.question.progress": "{{current}}/{{total}} 個問題", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "檔案總管", + "session.header.open.fileManager": "檔案管理員", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "終端機", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "開發效能診斷", + "debugBar.na": "不適用", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "最後一次完成的涉及工作階段頁面的路由轉換,從路由器啟動到穩定後的第一次繪製。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "過去 5 秒內的滾動幀率。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "過去 5 秒內最差的幀時間。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "過去 5 秒內超過 32ms 的幀。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "過去 5 秒內的阻塞時間和長任務計數。最大任務:{{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "過去 5 秒內觀察到的最差輸入延遲。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "過去 5 秒內的近似互動持續時間。這類似於 INP,而非官方的 Web Vitals INP。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "目前應用程式生命週期的累積版面配置位移。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用的 JS 堆積與堆積限制。僅限 Chromium。", + "debugBar.mem.tip": "使用的 JS 堆積與堆積限制。{{used}} / {{limit}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "空白鍵", + "common.key.backspace": "退格鍵", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "未知", + "error.page.circular": "[循環]", + "error.globalSDK.noServerAvailable": "無可用的伺服器", + "error.globalSDK.serverNotAvailable": "伺服器無法使用", + "error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗", + "error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗", + "error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗", + "error.childStore.storeCreateFailed": "建立儲存區失敗", + "terminal.connectionLost.abnormalClose": "WebSocket 異常關閉:{{code}}", } satisfies Partial> diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index a30d86d1809..11284b3d2d7 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -35,14 +35,14 @@ function isInitError(error: unknown): error is InitError { ) } -function safeJson(value: unknown): string { +function safeJson(value: unknown, circular: string): string { const seen = new WeakSet() const json = JSON.stringify( value, (_key, val) => { if (typeof val === "bigint") return val.toString() if (typeof val === "object" && val) { - if (seen.has(val)) return "[Circular]" + if (seen.has(val)) return circular seen.add(val) } return val @@ -54,14 +54,15 @@ function safeJson(value: unknown): string { function formatInitError(error: InitError, t: Translator): string { const data = error.data + const json = (value: unknown) => safeJson(value, t("error.page.circular")) switch (error.name) { case "MCPFailed": { const name = typeof data.name === "string" ? data.name : "" return t("error.chain.mcpFailed", { name }) } case "ProviderAuthError": { - const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" - const message = typeof data.message === "string" ? data.message : safeJson(data.message) + const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown") + const message = typeof data.message === "string" ? data.message : json(data.message) return t("error.chain.providerAuthFailed", { provider: providerID, message }) } case "APIError": { @@ -101,24 +102,24 @@ function formatInitError(error: InitError, t: Translator): string { ].join("\n") } case "ProviderInitError": { - const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" + const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown") return t("error.chain.providerInitFailed", { provider: providerID }) } case "ConfigJsonError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const path = typeof data.path === "string" ? data.path : json(data.path) const message = typeof data.message === "string" ? data.message : "" if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message }) return t("error.chain.configJsonInvalid", { path }) } case "ConfigDirectoryTypoError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) - const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir) - const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion) + const path = typeof data.path === "string" ? data.path : json(data.path) + const dir = typeof data.dir === "string" ? data.dir : json(data.dir) + const suggestion = typeof data.suggestion === "string" ? data.suggestion : json(data.suggestion) return t("error.chain.configDirectoryTypo", { dir, path, suggestion }) } case "ConfigFrontmatterError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) - const message = typeof data.message === "string" ? data.message : safeJson(data.message) + const path = typeof data.path === "string" ? data.path : json(data.path) + const message = typeof data.message === "string" ? data.message : json(data.message) return t("error.chain.configFrontmatterError", { path, message }) } case "ConfigInvalidError": { @@ -126,7 +127,7 @@ function formatInitError(error: InitError, t: Translator): string { ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) : [] const message = typeof data.message === "string" ? data.message : "" - const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const path = typeof data.path === "string" ? data.path : json(data.path) const line = message ? t("error.chain.configInvalidWithMessage", { path, message }) @@ -135,14 +136,15 @@ function formatInitError(error: InitError, t: Translator): string { return [line, ...issues].join("\n") } case "UnknownError": - return typeof data.message === "string" ? data.message : safeJson(data) + return typeof data.message === "string" ? data.message : json(data) default: if (typeof data.message === "string") return data.message - return safeJson(data) + return json(data) } } function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string { + const json = (value: unknown) => safeJson(value, t("error.page.circular")) if (!error) return t("error.chain.unknown") if (isInitError(error)) { @@ -204,7 +206,7 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag } const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" - return indent + safeJson(error) + return indent + json(error) } function formatError(error: unknown, t: Translator): string { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c1088622a26..bc04f9ecf27 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2159,7 +2159,7 @@ export default function Layout(props: ParentProps) { {language.t("command.provider.connect")} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 57ef1853d15..d917ce4c7aa 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -956,13 +956,15 @@ export default function Page() { return (
-
Create a Git repository
+
{language.t("session.review.noVcs.createGit.title")}
- Track, review, and undo changes in this project + {language.t("session.review.noVcs.createGit.description")}
) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 84f77ea4a3c..a5263cd743e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -196,7 +196,6 @@ export function SessionComposerRegion(props: { { const n = Math.min(store.tab + 1, total()) - return `${n} of ${total()} questions` + return language.t("session.question.progress", { current: n, total: total() }) }) const last = createMemo(() => store.tab >= total() - 1) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 5500de97a49..2cd660b39f4 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -9,6 +9,10 @@ import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { composerEnabled, composerProbe } from "@/testing/session-composer" +import { useLanguage } from "@/context/language" + +const doneToken = "\u0000done\u0000" +const totalToken = "\u0000total\u0000" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined @@ -38,11 +42,11 @@ function dot(status: Todo["status"]) { export function SessionTodoDock(props: { sessionID?: string todos: Todo[] - title: string collapseLabel: string expandLabel: string dockProgress: number }) { + const language = useLanguage() const [store, setStore] = createStore({ collapsed: false, height: 320, @@ -52,7 +56,12 @@ export function SessionTodoDock(props: { const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) - const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`) + const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) + const progress = createMemo(() => + language + .t("session.todo.progress", { done: doneToken, total: totalToken }) + .split(/(\u0000done\u0000|\u0000total\u0000)/), + ) const active = createMemo( () => @@ -137,10 +146,17 @@ export function SessionTodoDock(props: { opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, }} > - - of - -  {props.title.toLowerCase()} completed + + {(item) => + item() === doneToken ? ( + + ) : item() === totalToken ? ( + + ) : ( + {item()} + ) + } +
{ const title = input.title ?? "" const number = input.titleNumber ?? 0 - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + const isDefaultTitle = Number.isFinite(number) && number > 0 && isDefaultTerminalTitle(title, number) if (title && !isDefaultTitle) return title if (number > 0) return input.t("terminal.title.numbered", { number }) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3f009f4e0fb..0b2c1e1ce4f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,5 +1,6 @@ import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" +import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" import { Collapsible } from "./collapsible" import type { IconProps } from "./icon" @@ -233,12 +234,14 @@ export function GenericTool(props: { hideDetails?: boolean input?: Record }) { + const i18n = useI18n() + return ( void onNext: () => void }) { + const i18n = useI18n() + return (
props.onInput(e.currentTarget.value)} @@ -40,7 +43,7 @@ export function FileSearchBar(props: { type="button" class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" disabled={props.count() === 0} - aria-label="Previous match" + aria-label={i18n.t("ui.fileSearch.previousMatch")} onClick={props.onPrev} > @@ -49,7 +52,7 @@ export function FileSearchBar(props: { type="button" class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" disabled={props.count() === 0} - aria-label="Next match" + aria-label={i18n.t("ui.fileSearch.nextMatch")} onClick={props.onNext} > @@ -58,7 +61,7 @@ export function FileSearchBar(props: { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 17572923eb8..04d898134cf 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -9,7 +9,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { A, useNavigate, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -101,46 +102,94 @@ const SessionRow = (props: { warmPress: () => void warmFocus: () => void cancelHoverPrefetch: () => void -}): JSX.Element => ( - { - props.setHoverSession(undefined) - if (props.sidebarOpened()) return - props.clearHoverProjectSoon() - }} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - +}): JSX.Element => { + const [slot, setSlot] = createStore({ + open: false, + show: false, + fade: false, + }) + + let f: number | undefined + const clear = () => { + if (f !== undefined) window.clearTimeout(f) + f = undefined + } + + onCleanup(clear) + createEffect( + on( + () => props.isWorking(), + (on, prev) => { + clear() + if (on) { + setSlot({ open: true, show: true, fade: false }) + return + } + if (prev) { + setSlot({ open: false, show: true, fade: true }) + f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) + return + } + setSlot({ open: false, show: false, fade: false }) + }, + { defer: true }, + ), + ) + + return ( + { + props.setHoverSession(undefined) + if (props.sidebarOpened()) return + props.clearHoverProjectSoon() + }} + > + 0)}> +
0, + }} + aria-hidden="true" + /> + + +
+ + + + {props.session.title} +
- - {props.session.title} - -
-
-) + + ) +} const SessionHoverPreview = (props: { mobile?: boolean @@ -204,8 +253,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false + const pending = (sessionStore.message[props.session.id] ?? []).findLast( + (message) => + message.role === "assistant" && + typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number", + ) const status = sessionStore.session_status[props.session.id] - return status?.type === "busy" || status?.type === "retry" + return ( + pending !== undefined || + status?.type === "busy" || + status?.type === "retry" || + (status !== undefined && status.type !== "idle") + ) }) const tint = createMemo(() => { @@ -300,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
Promise language: ReturnType }): JSX.Element => ( -