From 8e09e8c6121f03244a1f25281b506a90bcb355d7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:47:51 -0500 Subject: [PATCH 01/13] feat: integrate multistep auth flows into desktop app (#18103) --- .../components/dialog-connect-provider.tsx | 150 ++++++++++++++++-- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + 18 files changed, 151 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index b042205cf4..e4fe9e7c4e 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -15,7 +15,6 @@ import { Link } from "@/components/link" import { useLanguage } from "@/context/language" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" import { DialogSelectModel } from "./dialog-select-model" import { DialogSelectProvider } from "./dialog-select-provider" @@ -23,7 +22,6 @@ export function DialogConnectProvider(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() - const platform = usePlatform() const language = useLanguage() const alive = { value: true } @@ -49,13 +47,14 @@ export function DialogConnectProvider(props: { provider: string }) { const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, - state: "pending" as undefined | "pending" | "complete" | "error", + state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) type Action = | { type: "method.select"; index: number } | { type: "method.reset" } + | { type: "auth.prompt" } | { type: "auth.pending" } | { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.error"; error: string } @@ -77,6 +76,11 @@ export function DialogConnectProvider(props: { provider: string }) { draft.error = undefined return } + if (action.type === "auth.prompt") { + draft.state = "prompt" + draft.error = undefined + return + } if (action.type === "auth.pending") { draft.state = "pending" draft.error = undefined @@ -120,7 +124,7 @@ export function DialogConnectProvider(props: { provider: string }) { return fallback } - async function selectMethod(index: number) { + async function selectMethod(index: number, inputs?: Record) { if (timer.current !== undefined) { clearTimeout(timer.current) timer.current = undefined @@ -130,6 +134,10 @@ export function DialogConnectProvider(props: { provider: string }) { dispatch({ type: "method.select", index }) if (method.type === "oauth") { + if (method.prompts?.length && !inputs) { + dispatch({ type: "auth.prompt" }) + return + } dispatch({ type: "auth.pending" }) const start = Date.now() await globalSDK.client.provider.oauth @@ -137,6 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) { { providerID: props.provider, method: index, + inputs, }, { throwOnError: true }, ) @@ -163,6 +172,122 @@ export function DialogConnectProvider(props: { provider: string }) { } } + function OAuthPromptsView() { + const [formStore, setFormStore] = createStore({ + value: {} as Record, + index: 0, + }) + + const prompts = createMemo(() => method()?.prompts ?? []) + const matches = (prompt: NonNullable[number]>, value: Record) => { + if (!prompt.when) return true + const actual = value[prompt.when.key] + if (actual === undefined) return false + return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value + } + const current = createMemo(() => { + const all = prompts() + const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) + if (index === -1) return + return { + index, + prompt: all[index], + } + }) + const valid = createMemo(() => { + const item = current() + if (!item || item.prompt.type !== "text") return false + const value = formStore.value[item.prompt.key] ?? "" + return value.trim().length > 0 + }) + + async function next(index: number, value: Record) { + if (store.methodIndex === undefined) return + const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) + if (next !== -1) { + setFormStore("index", next) + return + } + await selectMethod(store.methodIndex, value) + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const item = current() + if (!item || item.prompt.type !== "text") return + if (!valid()) return + await next(item.index, formStore.value) + } + + const item = () => current() + const text = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "text") return + return prompt + }) + const select = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "select") return + return prompt + }) + + return ( +
+ + + { + const prompt = text() + if (!prompt) return + setFormStore("value", prompt.key, value) + }} + /> + + + +
+
{select()?.message}
+
+ x.value} + current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} + onSelect={(value) => { + if (!value) return + const prompt = select() + if (!prompt) return + const nextValue = { + ...formStore.value, + [prompt.key]: value.value, + } + setFormStore("value", prompt.key, value.value) + void next(item()!.index, nextValue) + }} + > + {(option) => ( +
+
+ + {option.label} + {option.hint} +
+ )} + +
+
+ + + + ) + } + let listRef: ListRef | undefined function handleKey(e: KeyboardEvent) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) { @@ -301,7 +426,7 @@ export function DialogConnectProvider(props: { provider: string }) { error={formStore.error} />
@@ -314,12 +439,6 @@ export function DialogConnectProvider(props: { provider: string }) { error: undefined as string | undefined, }) - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -368,7 +487,7 @@ export function DialogConnectProvider(props: { provider: string }) { error={formStore.error} /> @@ -386,10 +505,6 @@ export function DialogConnectProvider(props: { provider: string }) { onMount(() => { void (async () => { - if (store.authorization?.url) { - platform.openLink(store.authorization.url) - } - const result = await globalSDK.client.provider.oauth .callback({ providerID: props.provider, @@ -470,6 +585,9 @@ export function DialogConnectProvider(props: { provider: string }) {
+ + +
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 720045a4d1..c8f58c796e 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -204,6 +204,7 @@ export const dict = { "common.cancel": "إلغاء", "common.connect": "اتصال", "common.disconnect": "قطع الاتصال", + "common.continue": "إرسال", "common.submit": "إرسال", "common.save": "حفظ", "common.saving": "جارٍ الحفظ...", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index a7d7433b02..3112e91bbe 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -204,6 +204,7 @@ export const dict = { "common.cancel": "Cancelar", "common.connect": "Conectar", "common.disconnect": "Desconectar", + "common.continue": "Enviar", "common.submit": "Enviar", "common.save": "Salvar", "common.saving": "Salvando...", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index ccdf2b6044..f2dbd8493c 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -221,6 +221,7 @@ export const dict = { "common.cancel": "Otkaži", "common.connect": "Poveži", "common.disconnect": "Prekini vezu", + "common.continue": "Pošalji", "common.submit": "Pošalji", "common.save": "Sačuvaj", "common.saving": "Čuvanje...", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index f1701094b5..e90e1071ad 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -219,6 +219,7 @@ export const dict = { "common.cancel": "Annuller", "common.connect": "Forbind", "common.disconnect": "Frakobl", + "common.continue": "Indsend", "common.submit": "Indsend", "common.save": "Gem", "common.saving": "Gemmer...", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 2dfeed7203..69658b29e9 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -209,6 +209,7 @@ export const dict = { "common.cancel": "Abbrechen", "common.connect": "Verbinden", "common.disconnect": "Trennen", + "common.continue": "Absenden", "common.submit": "Absenden", "common.save": "Speichern", "common.saving": "Speichert...", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7f6816de9e..72caed40ad 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -221,6 +221,7 @@ export const dict = { "common.open": "Open", "common.connect": "Connect", "common.disconnect": "Disconnect", + "common.continue": "Continue", "common.submit": "Submit", "common.save": "Save", "common.saving": "Saving...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 1cd47dfc79..9e36e4de6d 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -220,6 +220,7 @@ export const dict = { "common.cancel": "Cancelar", "common.connect": "Conectar", "common.disconnect": "Desconectar", + "common.continue": "Enviar", "common.submit": "Enviar", "common.save": "Guardar", "common.saving": "Guardando...", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index c7d89c3251..f53b3882c6 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -204,6 +204,7 @@ export const dict = { "common.cancel": "Annuler", "common.connect": "Connecter", "common.disconnect": "Déconnecter", + "common.continue": "Soumettre", "common.submit": "Soumettre", "common.save": "Enregistrer", "common.saving": "Enregistrement...", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 267411083f..d66a7341d5 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -203,6 +203,7 @@ export const dict = { "common.cancel": "キャンセル", "common.connect": "接続", "common.disconnect": "切断", + "common.continue": "送信", "common.submit": "送信", "common.save": "保存", "common.saving": "保存中...", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index bb57f99396..d534c27e8f 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -207,6 +207,7 @@ export const dict = { "common.cancel": "취소", "common.connect": "연결", "common.disconnect": "연결 해제", + "common.continue": "제출", "common.submit": "제출", "common.save": "저장", "common.saving": "저장 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 83d6a9903b..c23d0a2792 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -223,6 +223,7 @@ export const dict = { "common.cancel": "Avbryt", "common.connect": "Koble til", "common.disconnect": "Koble fra", + "common.continue": "Send inn", "common.submit": "Send inn", "common.save": "Lagre", "common.saving": "Lagrer...", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index db9ef18003..dac847b217 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -205,6 +205,7 @@ export const dict = { "common.cancel": "Anuluj", "common.connect": "Połącz", "common.disconnect": "Rozłącz", + "common.continue": "Prześlij", "common.submit": "Prześlij", "common.save": "Zapisz", "common.saving": "Zapisywanie...", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e1abb6e6cf..684d5deecd 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -220,6 +220,7 @@ export const dict = { "common.cancel": "Отмена", "common.connect": "Подключить", "common.disconnect": "Отключить", + "common.continue": "Отправить", "common.submit": "Отправить", "common.save": "Сохранить", "common.saving": "Сохранение...", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b522e4631b..80f0da94ec 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -220,6 +220,7 @@ export const dict = { "common.cancel": "ยกเลิก", "common.connect": "เชื่อมต่อ", "common.disconnect": "ยกเลิกการเชื่อมต่อ", + "common.continue": "ส่ง", "common.submit": "ส่ง", "common.save": "บันทึก", "common.saving": "กำลังบันทึก...", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 8542dff799..9041e0dd07 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -225,6 +225,7 @@ export const dict = { "common.cancel": "İptal", "common.connect": "Bağlan", "common.disconnect": "Bağlantı Kes", + "common.continue": "Gönder", "common.submit": "Gönder", "common.save": "Kaydet", "common.saving": "Kaydediliyor...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e762ba78d9..cf64ca9b2c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -242,6 +242,7 @@ export const dict = { "common.cancel": "取消", "common.connect": "连接", "common.disconnect": "断开连接", + "common.continue": "提交", "common.submit": "提交", "common.save": "保存", "common.saving": "保存中...", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 184c789ce3..02c00d17a2 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -220,6 +220,7 @@ export const dict = { "common.cancel": "取消", "common.connect": "連線", "common.disconnect": "中斷連線", + "common.continue": "提交", "common.submit": "提交", "common.save": "儲存", "common.saving": "儲存中...", From 1ac1a0287ccc7ee75b1ba6a0801bc18b74f502cb Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 00:45:24 -0400 Subject: [PATCH 02/13] anthropic legal requests (#18186) --- packages/opencode/src/cli/cmd/providers.ts | 1 - packages/opencode/src/plugin/index.ts | 5 - packages/opencode/src/provider/provider.ts | 3 +- packages/opencode/src/session/llm.ts | 18 +- .../src/session/prompt/anthropic-20250930.txt | 166 ------------------ packages/web/src/content/docs/providers.mdx | 20 ++- 6 files changed, 19 insertions(+), 194 deletions(-) delete mode 100644 packages/opencode/src/session/prompt/anthropic-20250930.txt diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index a2b7c5be15..581809e90e 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -349,7 +349,6 @@ export const ProvidersLoginCommand = cmd({ value: x.id, hint: { opencode: "recommended", - anthropic: "API key", openai: "ChatGPT Plus/Pro or API key", }[x.id], })), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8790efac49..755ce2c211 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -16,8 +16,6 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = ["opencode-anthropic-auth@0.0.13"] - // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] @@ -55,9 +53,6 @@ export namespace Plugin { let plugins = config.plugin ?? [] if (plugins.length) await Config.waitForDependencies() - if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - plugins = [...BUILTIN, ...plugins] - } for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0825e13b03..0255cf9bf1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -150,8 +150,7 @@ export namespace Provider { autoload: false, options: { headers: { - "anthropic-beta": - "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", }, }, } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index bcf1b3e6af..daf70180e5 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -207,18 +207,12 @@ export namespace LLM { maxOutputTokens, abortSignal: input.abort, headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : input.model.providerID !== "anthropic" - ? { - "User-Agent": `opencode/${Installation.VERSION}`, - } - : undefined), + ...(input.model.providerID.startsWith("opencode") && { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }), ...input.model.headers, ...headers, }, diff --git a/packages/opencode/src/session/prompt/anthropic-20250930.txt b/packages/opencode/src/session/prompt/anthropic-20250930.txt deleted file mode 100644 index e15080e6c8..0000000000 --- a/packages/opencode/src/session/prompt/anthropic-20250930.txt +++ /dev/null @@ -1,166 +0,0 @@ -You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. - -If the user asks for help or wants to give feedback inform them of the following: -- /help: Get help with using Claude Code -- To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues - -When the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the WebFetch tool to gather information to answer the question from Claude Code docs. The list of available docs is available at https://docs.claude.com/en/docs/claude-code/claude_code_docs_map.md. - -# Tone and style -You should be concise, direct, and to the point, while providing complete information and matching the level of detail you provide in your response with the level of complexity of the user's query or the work you have completed. -A concise response is generally less than 4 lines, not including tool calls or code generated. You should provide more detail when the task is complex or when the user asks you to. -IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. -IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. -Do not add additional code explanation summary unless requested by the user. After working on a file, briefly confirm that you have completed the task, rather than providing an explanation of what you did. -Answer the user's question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. Brief answers are best, but be sure to provide complete information. You MUST avoid extra preamble before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". - -Here are some examples to demonstrate appropriate verbosity: - -user: 2 + 2 -assistant: 4 - - - -user: what is 2+2? -assistant: 4 - - - -user: is 11 a prime number? -assistant: Yes - - - -user: what command should I run to list files in the current directory? -assistant: ls - - - -user: what command should I run to watch files in the current directory? -assistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files] -npm run dev - - - -user: How many golf balls fit inside a jetta? -assistant: 150000 - - - -user: what files are in the directory src/? -assistant: [runs ls and sees foo.c, bar.c, baz.c] -user: which file contains the implementation of foo? -assistant: src/foo.c - -When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). -Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. -If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. -Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. - -# Proactiveness -You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between: -- Doing the right thing when asked, including taking actions and follow-up actions -- Not surprising the user with actions you take without asking -For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. - -# Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. - -# Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. - -It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors - -I'm now going to run the build using Bash. - -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. - -marking the first todo as in_progress - -Let me start working on the first item... - -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats - -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - - - -Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including , as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration. - -# Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- Use the TodoWrite tool to plan the task if required - -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. - - -# Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. - -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. -- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. - - -Here is useful information about the environment you are running in: - -Working directory: /home/thdxr/dev/projects/anomalyco/opencode/packages/opencode -Is directory a git repo: Yes -Platform: linux -OS Version: Linux 6.12.4-arch1-1 -Today's date: 2025-09-30 - -You are powered by the model named Sonnet 4.5. The exact model ID is claude-sonnet-4-5-20250929. - -Assistant knowledge cutoff is January 2025. - - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. - - -IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. - -# Code References - -When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. - - -user: Where are errors from the client handled? -assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. - diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7f99320519..b5ddd15b3a 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -307,8 +307,6 @@ For custom inference profiles, use the model and provider name in the key and se ```txt ┌ Select auth method │ - │ Claude Pro/Max - │ Create an API Key │ Manually enter API Key └ ``` @@ -320,14 +318,20 @@ For custom inference profiles, use the model and provider name in the key and se ``` :::info -Using your Claude Pro/Max subscription in OpenCode is not officially supported by [Anthropic](https://anthropic.com). +There are plugins that allow you to use your Claude Pro/Max models with +OpenCode. Anthropic explicitly prohibits this. + +Previous versions of OpenCode came bundled with these plugins but that is no +longer the case as of 1.3.0 + +Other companies support freedom of choice with developer tooling - you can use +the following subscriptions in OpenCode with zero setup: + +- ChatGPT Plus +- Github Copilot +- Gitlab Duo ::: -##### Using API keys - -You can also select **Create an API Key** if you don't have a Pro/Max subscription. It'll also open your browser and ask you to login to Anthropic and give you a code you can paste in your terminal. - -Or if you already have an API key, you can select **Manually enter API Key** and paste it in your terminal. --- From 5a0bfa70616a55e4d3ffcfd74fddbe02abcb46c7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 04:46:39 +0000 Subject: [PATCH 03/13] chore: generate --- packages/web/src/content/docs/providers.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index b5ddd15b3a..0c0ba30a08 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -330,8 +330,7 @@ the following subscriptions in OpenCode with zero setup: - ChatGPT Plus - Github Copilot - Gitlab Duo -::: - + ::: --- From f4a9fe29a3b9ef4050d5d4ec45c1da74fcd42a21 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 19 Mar 2026 13:53:44 +0200 Subject: [PATCH 04/13] fix(app): ignore repeated Enter submits in prompt input (#18148) Co-authored-by: Shoubhit Dash --- packages/app/src/components/prompt-input.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c25235c65..55cfaa490f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1241,6 +1241,20 @@ export const PromptInput: Component = (props) => { // Note: Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + if (event.repeat) return + if ( + working() && + prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim().length === 0 && + imageAttachments().length === 0 && + commentCount() === 0 + ) { + return + } handleSubmit(event) } } From bd4527b4f28333a3c49faba43dc03d99e1e02ae2 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:32:11 +0100 Subject: [PATCH 05/13] fix(desktop): remote server switching (#17214) Co-authored-by: Brendan Allan --- packages/app/src/app.tsx | 39 ++++-- .../src/components/dialog-select-server.tsx | 2 +- .../app/src/components/status-popover.tsx | 2 +- packages/app/src/components/terminal.tsx | 24 ++-- packages/app/src/context/terminal.tsx | 122 +++++++++++------- .../app/src/pages/session/terminal-panel.tsx | 33 ++--- 6 files changed, 133 insertions(+), 89 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e370862212..857892123b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -265,6 +265,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ) } +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key @@ -275,20 +284,22 @@ export function AppInterface(props: { return ( - - - {routerProps.children}} - > - - - - - - - - + + + + {routerProps.children}} + > + + + + + + + + + ) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index eb039c14d6..f8d14cbb94 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -291,8 +291,8 @@ export function DialogSelectServer() { navigate("/") return } - server.setActive(ServerConnection.key(conn)) navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(conn))) } const handleAddChange = (value: string) => { diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 61facb84eb..063205f0c3 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -277,8 +277,8 @@ export function StatusPopover() { aria-disabled={isBlocked()} onClick={() => { if (isBlocked()) return - server.setActive(key) navigate("/") + queueMicrotask(() => server.setActive(key)) }} > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 9297d66266..aed46f1262 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -165,6 +165,12 @@ export const Terminal = (props: TerminalProps) => { const theme = useTheme() const language = useLanguage() const server = useServer() + const directory = sdk.directory + const client = sdk.client + const url = sdk.url + const auth = server.current?.http + const username = auth?.username ?? "opencode" + const password = auth?.password ?? "" let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id @@ -215,7 +221,7 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = (cols: number, rows: number) => { - return sdk.client.pty + return client.pty .update({ ptyID: id, size: { cols, rows }, @@ -474,7 +480,7 @@ export const Terminal = (props: TerminalProps) => { } const gone = () => - sdk.client.pty + client.pty .get({ ptyID: id }) .then(() => false) .catch((err) => { @@ -506,14 +512,14 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const url = new URL(sdk.url + `/pty/${id}/connect`) - url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(seek)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.username = server.current?.http.username ?? "opencode" - url.password = server.current?.http.password ?? "" + const next = new URL(url + `/pty/${id}/connect`) + next.searchParams.set("directory", directory) + next.searchParams.set("cursor", String(seek)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + next.username = username + next.password = password - const socket = new WebSocket(url) + const socket = new WebSocket(next) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index e65c167884..17355aab9a 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) onCleanup(unsub) + const update = (client: ReturnType["client"], pty: Partial & { id: string }) => { + const index = store.all.findIndex((x) => x.id === pty.id) + const previous = index >= 0 ? store.all[index] : undefined + if (index >= 0) { + setStore("all", index, (item) => ({ ...item, ...pty })) + } + client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) + } + + const clone = async (client: ReturnType["client"], id: string) => { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const next = await client.pty + .create({ + title: pty.title, + }) + .catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!next?.data) return + + const active = store.active === pty.id + + batch(() => { + setStore("all", index, { + id: next.data.id, + title: next.data.title ?? pty.title, + titleNumber: pty.titleNumber, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + rows: undefined, + cols: undefined, + }) + if (active) { + setStore("active", next.data.id) + } + }) + } + return { ready, all: createMemo(() => store.all), @@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) }, update(pty: Partial & { id: string }) { - const index = store.all.findIndex((x) => x.id === pty.id) - const previous = index >= 0 ? store.all[index] : undefined - if (index >= 0) { - setStore("all", index, (item) => ({ ...item, ...pty })) - } - sdk.client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((error: unknown) => { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) + update(sdk.client, pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) }, async clone(id: string) { - const index = store.all.findIndex((x) => x.id === id) - const pty = store.all[index] - if (!pty) return - const clone = await sdk.client.pty - .create({ - title: pty.title, - }) - .catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!clone?.data) return - - const active = store.active === pty.id - - batch(() => { - setStore("all", index, { - id: clone.data.id, - title: clone.data.title ?? pty.title, - titleNumber: pty.titleNumber, - // New PTY process, so start clean. - buffer: undefined, - cursor: undefined, - scrollY: undefined, - rows: undefined, - cols: undefined, - }) - if (active) { - setStore("active", clone.data.id) - } - }) + await clone(sdk.client, id) + }, + bind() { + const client = sdk.client + return { + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + update(pty: Partial & { id: string }) { + update(client, pty) + }, + async clone(id: string) { + await clone(client, id) + }, + } }, open(id: string) { setStore("active", id) @@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont trim: (id: string) => workspace().trim(id), trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), + bind: () => workspace(), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index d62d91c197..c663d7d671 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -280,21 +280,24 @@ export function TerminalPanel() {
- {(id) => ( - pty.id === id)}> - {(pty) => ( -
- terminal.trim(id)} - onCleanup={terminal.update} - onConnectError={() => terminal.clone(id)} - /> -
- )} -
- )} + {(id) => { + const ops = terminal.bind() + return ( + pty.id === id)}> + {(pty) => ( +
+ ops.trim(id)} + onCleanup={ops.update} + onConnectError={() => ops.clone(id)} + /> +
+ )} +
+ ) + }}
From cbf4b68fee8ff45c10a484b27f94e2dbe5f5dea2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 21:47:47 +0800 Subject: [PATCH 06/13] electron: lazily read updater enabled --- packages/desktop-electron/src/renderer/index.tsx | 4 ++-- packages/desktop-electron/src/renderer/updater.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 6719c04bee..30e882e237 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -152,12 +152,12 @@ const createPlatform = (): Platform => { storage, checkUpdate: async () => { - if (!UPDATER_ENABLED) return { updateAvailable: false } + if (!UPDATER_ENABLED()) return { updateAvailable: false } return window.api.checkUpdate() }, update: async () => { - if (!UPDATER_ENABLED) return + if (!UPDATER_ENABLED()) return await window.api.installUpdate() }, diff --git a/packages/desktop-electron/src/renderer/updater.ts b/packages/desktop-electron/src/renderer/updater.ts index fe9e601db8..ba2b458ece 100644 --- a/packages/desktop-electron/src/renderer/updater.ts +++ b/packages/desktop-electron/src/renderer/updater.ts @@ -1,6 +1,6 @@ import { initI18n, t } from "./i18n" -export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false +export const UPDATER_ENABLED = () => window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { await initI18n() From 84f60d97a0c37c9136dfd965a66a0c8685a19e71 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 21:59:14 +0800 Subject: [PATCH 07/13] app: fix workspace flicker when switching directories (#18207) Co-authored-by: Shoubhit Dash --- packages/app/e2e/actions.ts | 25 +++++ .../app/e2e/projects/projects-switch.spec.ts | 8 +- .../projects/workspace-new-session.spec.ts | 68 +++++++------ packages/app/e2e/projects/workspaces.spec.ts | 29 +++--- .../session/session-model-persistence.spec.ts | 14 ++- packages/app/src/app.tsx | 18 ++-- packages/app/src/pages/directory-layout.tsx | 97 +++++++++---------- packages/app/src/pages/layout.tsx | 77 +++++++++------ packages/app/src/pages/layout/helpers.test.ts | 6 +- packages/app/src/pages/layout/helpers.ts | 28 +++--- .../src/pages/layout/sidebar-workspace.tsx | 12 ++- 11 files changed, 215 insertions(+), 167 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index aa047fb287..88d71f94cf 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -1,3 +1,4 @@ +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" @@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) { return next } +export async function resolveSlug(slug: string) { + const directory = base64Decode(slug) + if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) + const resolved = await resolveDirectory(directory) + return { directory: resolved, slug: base64Encode(resolved), raw: slug } +} + +export async function waitDir(page: Page, directory: string) { + const target = await resolveDirectory(directory) + await expect + .poll( + async () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + return resolveSlug(slug) + .then((item) => item.directory) + .catch(() => "") + }, + { timeout: 45_000 }, + ) + .toBe(target) + return { directory: target, slug: base64Encode(target) } +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f5927..1416aec726 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" @@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(btn).toBeVisible() await btn.click({ force: true }) - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + await waitDir(page, space) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(rootButton).toBeVisible() await rootButton.click() + await waitDir(page, space) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 18fa46d329..0858f26273 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,18 +1,25 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -async function waitWorkspaceReady(page: Page, slug: string) { +function item(space: { slug: string; raw: string }) { + return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` +} + +function button(space: { slug: string; raw: string }) { + return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}` +} + +async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const row = page.locator(item(space)).first() try { - await item.hover({ timeout: 500 }) + await row.hover({ timeout: 500 }) return true } catch { return false @@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } -} - -async function openWorkspaceNewSession(page: Page, slug: string) { - await waitWorkspaceReady(page, slug) - - const item = page.locator(workspaceItemSelector(slug)).first() - await item.hover() - - const button = page.locator(workspaceNewSessionSelector(slug)).first() - await expect(button).toBeVisible() - await button.click({ force: true }) - - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await waitDir(page, next.directory) return next } -async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - const next = await openWorkspaceNewSession(page, slug) +async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { + await waitWorkspaceReady(page, space) + + const row = page.locator(item(space)).first() + await row.hover() + + const next = page.locator(button(space)).first() + await expect(next).toBeVisible() + await next.click({ force: true }) + + return waitDir(page, space.directory) +} + +async function createSessionFromWorkspace( + page: Page, + space: { slug: string; raw: string; directory: string }, + text: string, +) { + const next = await openWorkspaceNewSession(page, space) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(next) + await waitDir(page, next.directory) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) - return { sessionID, slug: next } + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next.slug } } async function sessionDirectory(directory: string, sessionID: string) { @@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a const first = await createWorkspace(page, root, []) trackDirectory(first.directory) - await waitWorkspaceReady(page, first.slug) + await waitWorkspaceReady(page, first) const second = await createWorkspace(page, root, [first.slug]) trackDirectory(second.directory) - await waitWorkspaceReady(page, second.slug) + await waitWorkspaceReady(page, second) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(firstSession.sessionID, first.directory) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aeeccb9bba..8ee899f18e 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,4 +1,3 @@ -import { base64Decode } from "@opencode-ai/util/encode" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -13,8 +12,10 @@ import { confirmDialog, openSidebar, openWorkspaceMenu, + resolveSlug, setWorkspacesEnabled, slugFromUrl, + waitDir, waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" @@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug]) - const dir = base64Decode(slug) + const next = await resolveSlug(await waitSlug(page, [rootSlug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { ) .toBe(true) - return { rootSlug, slug, directory: dir } + return { rootSlug, slug: next.slug, directory: next.directory } } test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { @@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - const workspaceSlug = await waitSlug(page, [slug]) - const workspaceDir = base64Decode(workspaceSlug) + const next = await resolveSlug(await waitSlug(page, [slug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => { ) .toBe(true) - await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - await cleanupTestProject(workspaceDir) + await cleanupTestProject(next.directory) }) }) @@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const activeDir = base64Decode(slugFromUrl(page.url())) + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) @@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug, prev]) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await waitDir(page, next.directory) + workspaces.push(next) await openSidebar(page) } diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 933d5e6f96..2c2e4e886d 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,7 +1,6 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next } async function waitWorkspace(page: Page, slug: string) { @@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page)) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await expect(page.locator(promptSelector)).toBeVisible() return currentDir(page) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 857892123b..9a282bbb70 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -46,21 +46,13 @@ import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -const Home = lazy(() => import("@/pages/home")) +const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
-const HomeRoute = () => ( - }> - - -) - const SessionRoute = () => ( - }> - - + ) @@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - {props.appChildren} - {props.children} + }> + {props.appChildren} + {props.children} + ) } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f993ffcd89..cd5e079a69 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,16 +1,15 @@ -import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" -import { createStore } from "solid-js/store" +import { DataProvider } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createMemo, createResource, type ParentProps, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" -import { LocalProvider } from "@/context/local" -import { useGlobalSDK } from "@/context/global-sdk" - -import { DataProvider } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" + function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() @@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() - const navigate = useNavigate() const location = useLocation() const language = useLanguage() const globalSDK = useGlobalSDK() - const directory = createMemo(() => decode64(params.dir) ?? "") - const [state, setState] = createStore({ invalid: "", resolved: "" }) + const navigate = useNavigate() + let invalid = "" - createEffect(() => { - if (!params.dir) return - const raw = directory() - if (!raw) { - if (state.invalid === params.dir) return - setState("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } + const [resolved] = createResource( + () => { + if (params.dir) return [location.pathname, params.dir] as const + }, + async ([pathname, b64Dir]) => { + const directory = decode64(b64Dir) - const current = params.dir - globalSDK - .createClient({ - directory: raw, - throwOnError: true, - }) - .path.get() - .then((x) => { - if (params.dir !== current) return - const next = x.data?.directory ?? raw - batch(() => { - setState("invalid", "") - setState("resolved", next) + if (!directory) { + if (invalid === params.dir) return + invalid = b64Dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), }) - if (next === raw) return - const path = location.pathname.slice(current.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - if (params.dir !== current) return - batch(() => { - setState("invalid", "") - setState("resolved", raw) + navigate("/", { replace: true }) + return + } + + return await globalSDK + .createClient({ + directory, + throwOnError: true, }) - }) - }) + .path.get() + .then((x) => { + const next = x.data?.directory ?? directory + invalid = "" + if (next === directory) return next + const path = pathname.slice(b64Dir.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + invalid = "" + return directory + }) + }, + ) return ( - + {(resolved) => ( resolved}> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4c3a00dfb5..cca14fd50c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return + const key = workspaceKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => p.worktree === directory) + const direct = projects.find((p) => workspaceKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -630,7 +631,10 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const key = workspaceKey(directory) + const project = projects.find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) @@ -1155,13 +1159,16 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { + const key = workspaceKey(directory) const project = layout.projects .list() - .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + .find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => root === directory || dirs.includes(directory), + ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), ) if (known) return known[0] @@ -1177,13 +1184,6 @@ export default function Layout(props: ParentProps) { return currentProject()?.worktree ?? projectRoot(directory) } - function touchProjectRoute() { - const root = currentProject()?.worktree - if (!root) return - if (server.projects.last() !== root) server.projects.touch(root) - return root - } - function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { setStore("lastProjectSession", root, { directory, id, at: Date.now() }) return root @@ -1347,8 +1347,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory + const key = workspaceKey(directory) + const index = list.findIndex((x) => workspaceKey(x.worktree) === key) + const active = workspaceKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1683,38 +1684,55 @@ export default function Layout(props: ParentProps) { const activeRoute = { session: "", sessionProject: "", + directory: "", } createEffect( on( - () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, - ([ready, dir, id]) => { - if (!ready || !dir) { + () => { + const dir = params.dir + const directory = dir ? decode64(dir) : undefined + const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : "" + return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const + }, + ([ready, dir, id, root, directory, resolved]) => { + if (!ready || !dir || !directory) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } - const directory = decode64(dir) - if (!directory) return - - const root = touchProjectRoute() ?? activeProjectRoot(directory) - if (!id) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } + const next = resolved || directory const session = `${dir}/${id}` - if (session !== activeRoute.session) { + + if (!root) { activeRoute.session = session - activeRoute.sessionProject = syncSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = "" + return + } + + if (server.projects.last() !== root) server.projects.touch(root) + + const changed = session !== activeRoute.session || next !== activeRoute.directory + if (changed) { + activeRoute.session = session + activeRoute.directory = next + activeRoute.sessionProject = syncSessionRoute(next, id, root) return } if (root === activeRoute.sessionProject) return - activeRoute.sessionProject = rememberSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = rememberSessionRoute(next, id, root) }, ), ) @@ -1778,8 +1796,13 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? currentDir() : undefined - const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const extra = + directory && + workspaceKey(directory) !== workspaceKey(local) && + !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + ? directory + : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9dbc6c72d2..1fe52d47a0 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -104,14 +104,14 @@ describe("layout deep links", () => { describe("layout workspace helpers", () => { test("normalizes trailing slash in workspace key", () => { expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") }) test("preserves posix and drive roots in workspace key", () => { expect(workspaceKey("/")).toBe("/") expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:\\") - expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:\\")).toBe("C:/") + expect(workspaceKey("C://")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/") }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f574..886ffd26a1 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,11 +1,17 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +type SessionStore = { + session?: Session[] + path: { directory: string } +} + export const workspaceKey = (directory: string) => { - const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) - if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` - if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" - return directory.replace(/[\\/]+$/, "") + const value = directory.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") } function sortSessions(now: number) { @@ -25,13 +31,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) -export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => - stores - .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) - .sort(sortSessions(now))[0] +export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) + +export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record, @@ -40,9 +44,9 @@ export function hasProjectPermissions( return Object.values(request).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[]) => { +export const childMapByParent = (sessions: Session[] | undefined) => { const map = new Map() - for (const session of sessions) { + for (const session of sessions ?? []) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e6..127626febe 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -332,12 +332,13 @@ export const SortableWorkspace = (props: { const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") - const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) + const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.directory) @@ -472,8 +473,9 @@ export const LocalWorkspace = (props: { const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const loading = createMemo(() => !booted() && count() === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) From e6f521477959b1009153d8310fb90ac41d766b8b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 19:29:29 +0530 Subject: [PATCH 08/13] feat: add git-backed session review modes (#17961) --- .../app/src/context/global-sync/bootstrap.ts | 2 +- .../src/context/global-sync/event-reducer.ts | 4 +- packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/session.tsx | 194 ++++++++++-- .../src/pages/session/session-side-panel.tsx | 54 ++-- .../pages/session/use-session-commands.tsx | 6 +- packages/opencode/src/cli/cmd/github.ts | 10 +- packages/opencode/src/cli/cmd/pr.ts | 8 +- packages/opencode/src/effect/instances.ts | 2 +- packages/opencode/src/effect/runtime.ts | 2 + packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/git/effect.ts | 298 ++++++++++++++++++ packages/opencode/src/git/index.ts | 64 ++++ packages/opencode/src/project/project.ts | 10 +- packages/opencode/src/project/vcs.ts | 167 ++++++++-- packages/opencode/src/server/server.ts | 38 ++- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/util/git.ts | 35 -- packages/opencode/src/worktree/index.ts | 44 +-- packages/opencode/test/git/git.test.ts | 107 +++++++ .../opencode/test/project/project.test.ts | 17 +- packages/opencode/test/project/vcs.test.ts | 111 ++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 33 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 23 +- packages/ui/src/i18n/en.ts | 2 + 26 files changed, 1072 insertions(+), 183 deletions(-) create mode 100644 packages/opencode/src/git/effect.ts create mode 100644 packages/opencode/src/git/index.ts delete mode 100644 packages/opencode/src/util/git.ts create mode 100644 packages/opencode/test/git/git.test.ts diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..c71e00f6fa 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: { input.sdk.vcs.get().then((x) => { const next = x.data ?? input.store.vcs input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) + if (next) input.vcsCache.setStore("value", next) }), input.sdk.permission.list().then((x) => { const grouped = groupBySession( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f..0e243e95a3 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad..c7e0c95ea3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -533,6 +533,8 @@ export const dict = { "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", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081..6fe2e56b8e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { batch, @@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] +type ChangeMode = "git" | "branch" | "session" | "turn" +type VcsMode = "git" | "branch" + type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -415,15 +418,16 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasReview = createMemo(() => reviewCount() > 0) + const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasSessionReview = createMemo(() => sessionCount() > 0) + const canReview = createMemo(() => !!params.id) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview, + hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -499,11 +503,22 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "session" as "session" | "turn", + changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) + const [vcs, setVcs] = createStore({ + diff: { + git: [] as FileDiff[], + branch: [] as FileDiff[], + }, + ready: { + git: false, + branch: false, + }, + }) + const [followup, setFollowup] = createStore({ items: {} as Record, sending: {} as Record, @@ -531,6 +546,40 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined + const vcsTask = new Map>() + + const resetVcs = () => { + vcsTask.clear() + setVcs({ + diff: { git: [], branch: [] }, + ready: { git: false, branch: false }, + }) + } + + const loadVcs = (mode: VcsMode, force = false) => { + if (sync.project?.vcs !== "git") return Promise.resolve() + if (vcs.ready[mode] && !force) return Promise.resolve() + const current = vcsTask.get(mode) + if (current) return current + + const task = sdk.client.vcs + .diff({ mode }) + .then((result) => { + setVcs("diff", mode, result.data ?? []) + setVcs("ready", mode, true) + }) + .catch((error) => { + console.debug("[session-review] failed to load vcs diff", { mode, error }) + setVcs("diff", mode, []) + setVcs("ready", mode, true) + }) + .finally(() => { + vcsTask.delete(mode) + }) + + vcsTask.set(mode, task) + return task + } createComputed((prev) => { const open = desktopReviewOpen() @@ -546,7 +595,38 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const changesOptions = createMemo(() => { + const list: ChangeMode[] = [] + const git = sync.project?.vcs === "git" + if (git) list.push("git") + if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) { + list.push("branch") + } + list.push("session", "turn") + return list + }) + const vcsMode = createMemo(() => { + if (store.changes === "git" || store.changes === "branch") return store.changes + }) + const reviewDiffs = createMemo(() => { + if (store.changes === "git") return vcs.diff.git + if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "session") return diffs() + return turnDiffs() + }) + const reviewCount = createMemo(() => { + if (store.changes === "git") return vcs.diff.git.length + if (store.changes === "branch") return vcs.diff.branch.length + if (store.changes === "session") return sessionCount() + return turnDiffs().length + }) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + if (store.changes === "git") return vcs.ready.git + if (store.changes === "branch") return vcs.ready.branch + if (store.changes === "session") return !hasSessionReview() || diffsReady() + return true + }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -615,10 +695,10 @@ export default function Page() { const diffsReady = createMemo(() => { const id = params.id if (!id) return true - if (!hasReview()) return true + if (!hasSessionReview()) return true return sync.data.session_diff[id] !== undefined }) - const reviewEmptyKey = createMemo(() => { + const sessionEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -741,13 +821,23 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "session") + setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) + createEffect( + on( + () => sdk.directory, + () => { + resetVcs() + }, + { defer: true }, + ), + ) + createEffect( on( () => params.dir, @@ -870,6 +960,38 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", + ) + + createEffect(() => { + const list = changesOptions() + if (list.includes(store.changes)) return + const next = list[0] + if (!next) return + setStore("changes", next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + void loadVcs(mode) + }) + + createEffect( + on( + () => sync.data.session_status[params.id ?? ""]?.type, + (next, prev) => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void loadVcs(mode, true) + }, + { defer: true }, + ), + ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -916,21 +1038,23 @@ export default function Page() { loadFile: file.load, }) - const changesOptions = ["session", "turn"] as const - const changesOptionsList = [...changesOptions] - const changesTitle = () => { - if (!hasReview()) { + if (!canReview()) { return null } + const label = (option: ChangeMode) => { + if (option === "git") return language.t("ui.sessionReview.title.git") + if (option === "branch") return language.t("ui.sessionReview.title.branch") + if (option === "session") return language.t("ui.sessionReview.title") + return language.t("ui.sessionReview.title.lastTurn") + } + return ( + option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn") + } onSelect={(option) => option && setStore("changes", option)} variant="ghost" size="small" @@ -1070,34 +939,20 @@ export default function Page() { ) } - const empty = (text: string) => ( + const emptyTurn = () => (
-
{text}
+
{language.t("session.review.noChanges")}
) - const reviewEmptyText = createMemo(() => { - if (store.changes === "git") return language.t("session.review.noUncommittedChanges") - if (store.changes === "branch") return language.t("session.review.noBranchChanges") - if (store.changes === "turn") return language.t("session.review.noChanges") - return language.t(sessionEmptyKey()) - }) - const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { - if (store.changes === "git" || store.changes === "branch") { - if (!reviewReady()) return
{language.t("session.review.loadingChanges")}
- return empty(reviewEmptyText()) - } + if (store.changes === "turn") return emptyTurn() - if (store.changes === "turn") { - return empty(reviewEmptyText()) - } - - if (hasSessionReview() && !diffsReady()) { + if (hasReview() && !diffsReady()) { return
{language.t("session.review.loadingChanges")}
} - if (sessionEmptyKey() === "session.review.noVcs") { + if (reviewEmptyKey() === "session.review.noVcs") { return (
@@ -1117,7 +972,7 @@ export default function Page() { return (
-
{reviewEmptyText()}
+
{language.t(reviewEmptyKey())}
) } @@ -1165,18 +1020,6 @@ export default function Page() {
) - const mobileReview = () => - reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - 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", - }) - createEffect( on( activeFileTab, @@ -1233,7 +1076,7 @@ export default function Page() { const pending = tree.pendingDiff if (!pending) return if (!tree.reviewScroll) return - if (!reviewReady()) return + if (!diffsReady()) return const attempt = (count: number) => { if (tree.pendingDiff !== pending) return @@ -1810,7 +1653,7 @@ export default function Page() {
- + - }> -
{mobileReview()}
-
+
@@ -1958,12 +1808,6 @@ export default function Page() {
boolean - diffs: () => FileDiff[] - diffsReady: () => boolean - empty: () => string - hasReview: () => boolean - reviewCount: () => number reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void @@ -40,11 +34,12 @@ export function SessionSidePanel(props: { size: Sizing }) { const layout = useLayout() + const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() const dialog = useDialog() - const { sessionKey, tabs, view } = useSessionLayout() + const { params, sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -59,7 +54,24 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -70,7 +82,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" @@ -124,7 +136,7 @@ export function SessionSidePanel(props: { pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview: props.canReview, + hasReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -229,12 +241,12 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - +
{language.t("session.tab.review")}
- -
{props.reviewCount()}
+ +
{reviewCount()}
@@ -291,7 +303,7 @@ export function SessionSidePanel(props: {
- + {props.reviewPanel()} @@ -365,10 +377,8 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {reviewCount()}{" "} + {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} {language.t("session.files.all")} @@ -376,9 +386,9 @@ export function SessionSidePanel(props: { - + {language.t("common.loading")} @@ -397,7 +407,11 @@ export function SessionSidePanel(props: { /> - {empty(props.empty())} + + {empty( + language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()), + )} + diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f45374359a..1a2e777f52 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -56,7 +56,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!id) return return sync.session.get(id) } - const hasReview = () => !!params.id + const hasReview = () => { + const id = params.id + if (!id) return false + return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0 + } const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 1d8542133c..edd9d75610 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -30,7 +30,7 @@ import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" type GitHubAuthor = { login: string @@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() + const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree }) + const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index a3a15afbcb..8826fe343e 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -2,7 +2,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Instance } from "@/project/instance" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" export const PrCommand = cmd({ command: "pr ", @@ -67,9 +67,9 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() + const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: Instance.worktree, }) UI.println(`Added fork remote: ${remoteName}`) @@ -77,7 +77,7 @@ export const PrCommand = cmd({ // Set upstream to the fork so pushes go there const headRefName = prInfo.headRefName - await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { cwd: Instance.worktree, }) } diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 5ff1fc121c..c05458d5df 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -40,7 +40,7 @@ function lookup(_key: string) { Layer.fresh(PermissionNext.layer), Layer.fresh(ProviderAuth.defaultLayer), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.defaultLayer), + Layer.fresh(Vcs.layer), Layer.fresh(FileTime.layer).pipe(Layer.orDie), Layer.fresh(Format.layer), Layer.fresh(File.layer), diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02209e7a03..f52203b222 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,7 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { AccountEffect } from "@/account/effect" import { AuthEffect } from "@/auth/effect" -import { GitEffect } from "@/git/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -10,7 +9,6 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( AccountEffect.defaultLayer, // - GitEffect.defaultLayer, TruncateEffect.defaultLayer, Instances.layer, ).pipe(Layer.provideMerge(AuthEffect.layer)), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 79f8d9f7d9..6e9b917271 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { Git } from "@/git" +import { git } from "@/util/git" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" import fs from "fs" @@ -440,7 +440,7 @@ export namespace File { return yield* Effect.promise(async () => { const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { cwd: instance.directory, }) ).text() @@ -460,7 +460,7 @@ export namespace File { } const untrackedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -493,7 +493,7 @@ export namespace File { } const deletedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -584,17 +584,17 @@ export namespace File { if (instance.project.vcs === "git") { let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) ).text() if (!diff.trim()) { diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory, }) ).text() } if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index a689e63851..16ab3c6d33 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,7 +10,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { Flag } from "@/flag/flag" import { Instance } from "@/project/instance" -import { Git } from "@/git" +import { git } from "@/util/git" import { lazy } from "@/util/lazy" import { Config } from "../config/config" import { FileIgnore } from "./ignore" @@ -117,7 +117,7 @@ export namespace FileWatcher { if (instance.project.vcs === "git") { const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { + git(["rev-parse", "--git-dir"], { cwd: instance.project.worktree, }), ) diff --git a/packages/opencode/src/git/effect.ts b/packages/opencode/src/git/effect.ts deleted file mode 100644 index feff72b448..0000000000 --- a/packages/opencode/src/git/effect.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" -import { Effect, Layer, ServiceMap, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" - -export namespace GitEffect { - const cfg = [ - "--no-optional-locks", - "-c", - "core.autocrlf=false", - "-c", - "core.fsmonitor=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ] as const - - function out(result: { text(): string }) { - return result.text().trim() - } - - function split(text: string) { - return text.split("\0").filter(Boolean) - } - - export type Kind = "added" | "deleted" | "modified" - - export type Base = { - readonly name: string - readonly ref: string - } - - export type Item = { - readonly file: string - readonly code: string - readonly status: Kind - } - - export type Stat = { - readonly file: string - readonly additions: number - readonly deletions: number - } - - export interface Result { - readonly exitCode: number - readonly text: () => string - readonly stdout: Buffer - readonly stderr: Buffer - } - - export interface Options { - readonly cwd: string - readonly env?: Record - } - - export interface Interface { - readonly run: (args: string[], opts: Options) => Effect.Effect - readonly text: (args: string[], opts: Options) => Effect.Effect - readonly lines: (args: string[], opts: Options) => Effect.Effect - readonly branch: (cwd: string) => Effect.Effect - readonly prefix: (cwd: string) => Effect.Effect - readonly defaultBranch: (cwd: string) => Effect.Effect - readonly hasHead: (cwd: string) => Effect.Effect - readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect - readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect - readonly status: (cwd: string) => Effect.Effect - readonly diff: (cwd: string, ref: string) => Effect.Effect - readonly stats: (cwd: string, ref: string) => Effect.Effect - } - - function kind(code: string | undefined): Kind { - if (code === "??") return "added" - if (code?.includes("U")) return "modified" - if (code?.includes("A") && !code.includes("D")) return "added" - if (code?.includes("D") && !code.includes("A")) return "deleted" - return "modified" - } - - function parseStatus(text: string) { - return split(text).flatMap((item) => { - const file = item.slice(3) - if (!file) return [] - const code = item.slice(0, 2) - return [{ file, code, status: kind(code) } satisfies Item] - }) - } - - function parseNames(text: string) { - const list = split(text) - const out: Item[] = [] - for (let i = 0; i < list.length; i += 2) { - const code = list[i] - const file = list[i + 1] - if (!code || !file) continue - out.push({ file, code, status: kind(code) }) - } - return out - } - - function parseStats(text: string) { - const out: Stat[] = [] - for (const item of split(text)) { - const a = item.indexOf("\t") - const b = item.indexOf("\t", a + 1) - if (a === -1 || b === -1) continue - const file = item.slice(b + 1) - if (!file) continue - const adds = item.slice(0, a) - const dels = item.slice(a + 1, b) - const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) - const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) - out.push({ - file, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - }) - } - return out - } - - export class Service extends ServiceMap.Service()("@opencode/Git") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fn("Git.run")( - function* (args: string[], opts: Options) { - const proc = ChildProcess.make("git", [...cfg, ...args], { - cwd: opts.cwd, - env: opts.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - return { - exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), - } satisfies Result - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(1), - text: () => "", - stdout: Buffer.alloc(0), - stderr: Buffer.from(String(err)), - }), - ), - ) - - const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { - return (yield* run(args, opts)).text() - }) - - const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { - return (yield* text(args, opts)) - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean) - }) - - const refs = Effect.fnUntraced(function* (cwd: string) { - return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) - }) - - const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { - const result = yield* run(["config", "init.defaultBranch"], { cwd }) - if (result.exitCode !== 0) return - const name = out(result) - if (!name || !list.includes(name)) return - const ref = yield* run(["rev-parse", "--verify", name], { cwd }) - if (ref.exitCode !== 0) return - return { name, ref: name } satisfies Base - }) - - const remoteHead = Effect.fnUntraced(function* (cwd: string, remote: string) { - const result = yield* run(["ls-remote", "--symref", remote, "HEAD"], { cwd }) - if (result.exitCode !== 0) return - for (const line of result.text().split("\n")) { - const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim()) - if (!match?.[1]) continue - return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base - } - }) - - const primary = Effect.fnUntraced(function* (cwd: string) { - const list = yield* lines(["remote"], { cwd }) - if (list.includes("origin")) return "origin" - if (list.length === 1) return list[0] - if (list.includes("upstream")) return "upstream" - return list[0] - }) - - const branch = Effect.fn("Git.branch")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) - if (result.exitCode !== 0) return "" - return out(result) - }) - - const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { - const remote = yield* primary(cwd) - if (remote) { - const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) - if (head.exitCode === 0) { - const ref = out(head).replace(/^refs\/remotes\//, "") - const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" - if (name) return { name, ref } satisfies Base - } - - const next = yield* remoteHead(cwd, remote) - if (next) return next - } - - const list = yield* refs(cwd) - const next = yield* configured(cwd, list) - if (next) return next - for (const name of ["main", "master"]) { - if (list.includes(name)) return { name, ref: name } satisfies Base - } - }) - - const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) - return result.exitCode === 0 - }) - - const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { - const result = yield* run(["merge-base", base, head], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { - const target = prefix ? `${prefix}${file}` : file - const result = yield* run(["show", `${ref}:${target}`], { cwd }) - if (result.exitCode !== 0) return "" - return result.text() - }) - - const status = Effect.fn("Git.status")(function* (cwd: string) { - return parseStatus( - yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { cwd }), - ) - }) - - const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { - return parseNames( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), - ) - }) - - const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { - return parseStats( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), - ) - }) - - return Service.of({ - run, - text, - lines, - branch, - prefix, - defaultBranch, - hasHead, - mergeBase, - show, - status, - diff, - stats, - }) - }), - ) - - const platformLayer = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), - ) - - export const defaultLayer = layer.pipe(Layer.provide(platformLayer)) -} diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts deleted file mode 100644 index 1b04353b10..0000000000 --- a/packages/opencode/src/git/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect } from "effect" -import { runtime } from "@/effect/runtime" -import { GitEffect } from "./effect" - -function runPromise(f: (service: GitEffect.Interface) => Effect.Effect): Promise { - return runtime.runPromise(GitEffect.Service.use(f)) -} - -export namespace Git { - export type Kind = GitEffect.Kind - export type Base = GitEffect.Base - export type Item = GitEffect.Item - export type Stat = GitEffect.Stat - export type Result = GitEffect.Result - export type Options = GitEffect.Options - - export function run(args: string[], opts: Options) { - return runPromise((git) => git.run(args, opts)) - } - - export function text(args: string[], opts: Options) { - return runPromise((git) => git.text(args, opts)) - } - - export function lines(args: string[], opts: Options) { - return runPromise((git) => git.lines(args, opts)) - } - - export function branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - - export function defaultBranch(cwd: string) { - return runPromise((git) => git.defaultBranch(cwd)) - } - - export function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } -} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f2df9982e1..1cef41c85c 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -11,7 +11,7 @@ import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" -import { Git } from "@/git" +import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" @@ -119,7 +119,7 @@ export namespace Project { } } - const worktree = await Git.run(["rev-parse", "--git-common-dir"], { + const worktree = await git(["rev-parse", "--git-common-dir"], { cwd: sandbox, }) .then(async (result) => { @@ -147,7 +147,7 @@ export namespace Project { // generate id from root commit if (!id) { - const roots = await Git.run(["rev-list", "--max-parents=0", "HEAD"], { + const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox, }) .then(async (result) => @@ -184,7 +184,7 @@ export namespace Project { } } - const top = await Git.run(["rev-parse", "--show-toplevel"], { + const top = await git(["rev-parse", "--show-toplevel"], { cwd: sandbox, }) .then(async (result) => gitpath(sandbox, await result.text())) @@ -349,7 +349,7 @@ export namespace Project { if (input.project.vcs === "git") return input.project if (!which("git")) throw new Error("Git is not installed") - const result = await Git.run(["init", "--quiet"], { + const result = await git(["init", "--quiet"], { cwd: input.directory, }) if (result.exitCode !== 0) { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 7358ec4144..9e85571c49 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,122 +1,16 @@ -import { AppFileSystem } from "@/filesystem" import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" -import { GitEffect } from "@/git/effect" -import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import path from "path" +import { git } from "@/util/git" import { Instance } from "./instance" import z from "zod" -function count(text: string) { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) - -function stats(list: GitEffect.Stat[]) { - const out = new Map() - for (const item of list) { - out.set(item.file, { - additions: item.additions, - deletions: item.deletions, - }) - } - return out -} - -function merge(...lists: GitEffect.Item[][]) { - const out = new Map() - for (const list of lists) { - for (const item of list) { - if (!out.has(item.file)) out.set(item.file, item) - } - } - return [...out.values()] -} - -const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, - list: GitEffect.Item[], - nums: Map, -) { - const base = ref ? yield* git.prefix(cwd) : "" - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = nums.get(item.file) - return { - file: item.file, - before, - after, - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies Snapshot.FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) -}) - -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) { - return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - } - const [list, nums] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, stats(nums)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string, -) { - const [list, nums, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { - concurrency: 3, - }) - return yield* files( - fs, - git, - cwd, - ref, - merge( - list, - extra.filter((item) => item.code === "??"), - ), - stats(nums), - ) -}) - export namespace Vcs { const log = Log.create({ service: "vcs" }) - export const Mode = z.enum(["git", "branch"]) - export type Mode = z.infer - export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -128,8 +22,7 @@ export namespace Vcs { export const Info = z .object({ - branch: z.string().optional(), - default_branch: z.string().optional(), + branch: z.string(), }) .meta({ ref: "VcsInfo", @@ -138,8 +31,6 @@ export namespace Vcs { export interface Interface { readonly branch: () => Effect.Effect - readonly defaultBranch: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Vcs") {} @@ -148,18 +39,20 @@ export namespace Vcs { Service, Effect.gen(function* () { const instance = yield* InstanceContext - const fs = yield* AppFileSystem.Service - const git = yield* GitEffect.Service - let current: string | undefined - let root: GitEffect.Base | undefined + let currentBranch: string | undefined if (instance.project.vcs === "git") { - const get = () => Effect.runPromise(git.branch(instance.directory)) + const getCurrentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: instance.project.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } - ;[current, root] = yield* Effect.all([git.branch(instance.directory), git.defaultBranch(instance.directory)], { - concurrency: 2, - }) - log.info("initialized", { branch: current, default_branch: root?.name }) + currentBranch = yield* Effect.promise(() => getCurrentBranch()) + log.info("initialized", { branch: currentBranch }) yield* Effect.acquireRelease( Effect.sync(() => @@ -167,11 +60,12 @@ export namespace Vcs { FileWatcher.Event.Updated, Instance.bind(async (evt) => { if (!evt.properties.file.endsWith("HEAD")) return - const next = await get() - if (next === current) return - log.info("branch changed", { from: current, to: next }) - current = next - Bus.publish(Event.BranchUpdated, { branch: next }) + const next = await getCurrentBranch() + if (next !== currentBranch) { + log.info("branch changed", { from: currentBranch, to: next }) + currentBranch = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } }), ), ), @@ -181,30 +75,9 @@ export namespace Vcs { return Service.of({ branch: Effect.fn("Vcs.branch")(function* () { - return current - }), - defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { - return root?.name - }), - diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { - if (instance.project.vcs !== "git") return [] - if (mode === "git") { - const ok = yield* git.hasHead(instance.directory) - return yield* track(fs, git, instance.directory, ok ? "HEAD" : undefined) - } - - if (!root) return [] - if (current && current === root.name) return [] - const ref = yield* git.mergeBase(instance.directory, root.ref) - if (!ref) return [] - return yield* compare(fs, git, instance.directory, ref) + return currentBranch }), }) }), ) - - export const defaultLayer = layer.pipe( - Layer.provide(GitEffect.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b0872b5574..c485654fdf 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,7 +41,6 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" -import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -333,39 +332,10 @@ export namespace Server { }, }), async (c) => { - const [branch, default_branch] = await Promise.all([ - runPromiseInstance(Vcs.Service.use((s) => s.branch())), - runPromiseInstance(Vcs.Service.use((s) => s.defaultBranch())), - ]) - return c.json({ branch, default_branch }) - }, - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode, - }), - ), - async (c) => { - const mode = c.req.valid("query").mode - return c.json(await runPromiseInstance(Vcs.Service.use((s) => s.diff(mode)))) + const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) + return c.json({ + branch, + }) }, ) .get( diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 99067594d3..a78607cdfd 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -8,7 +8,7 @@ import { Lock } from "../util/lock" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { Glob } from "../util/glob" -import { Git } from "@/git" +import { git } from "@/util/git" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -49,7 +49,7 @@ export namespace Storage { } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue - const result = await Git.run(["rev-list", "--max-parents=0", "--all"], { + const result = await git(["rev-list", "--max-parents=0", "--all"], { cwd: worktree, }) const [id] = result diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts new file mode 100644 index 0000000000..731131357f --- /dev/null +++ b/packages/opencode/src/util/git.ts @@ -0,0 +1,35 @@ +import { Process } from "./process" + +export interface GitResult { + exitCode: number + text(): string + stdout: Buffer + stderr: Buffer +} + +/** + * Run a git command. + * + * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance + * issues in embedded/client environments. + */ +export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { + return Process.run(["git", ...args], { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + nothrow: true, + }) + .then((result) => ({ + exitCode: result.code, + text: () => result.stdout.toString(), + stdout: result.stdout, + stderr: result.stderr, + })) + .catch((error) => ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(error instanceof Error ? error.message : String(error)), + })) +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d1f52fff96..6ed0e48202 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,7 +12,7 @@ import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" -import { Git } from "@/git" +import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -250,14 +250,14 @@ export namespace Worktree { } async function sweep(root: string) { - const first = await Git.run(["clean", "-ffdx"], { cwd: root }) + const first = await git(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first const entries = failed(first) if (!entries.length) return first await prune(root, entries) - return Git.run(["clean", "-ffdx"], { cwd: root }) + return git(["clean", "-ffdx"], { cwd: root }) } async function canonical(input: string) { @@ -276,7 +276,7 @@ export namespace Worktree { if (await exists(directory)) continue const ref = `refs/heads/${branch}` - const branchCheck = await Git.run(["show-ref", "--verify", "--quiet", ref], { + const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree, }) if (branchCheck.exitCode === 0) continue @@ -348,7 +348,7 @@ export namespace Worktree { } export async function createFromInfo(info: Info, startCommand?: string) { - const created = await Git.run(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: Instance.worktree, }) if (created.exitCode !== 0) { @@ -362,7 +362,7 @@ export namespace Worktree { return () => { const start = async () => { - const populated = await Git.run(["reset", "--hard"], { cwd: info.directory }) + const populated = await git(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "Failed to populate worktree" log.error("worktree checkout failed", { directory: info.directory, message }) @@ -479,10 +479,10 @@ export namespace Worktree { const stop = async (target: string) => { if (!(await exists(target))) return - await Git.run(["fsmonitor--daemon", "stop"], { cwd: target }) + await git(["fsmonitor--daemon", "stop"], { cwd: target }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -499,11 +499,11 @@ export namespace Worktree { } await stop(entry.path) - const removed = await Git.run(["worktree", "remove", "--force", entry.path], { + const removed = await git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree, }) if (removed.exitCode !== 0) { - const next = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", @@ -520,7 +520,7 @@ export namespace Worktree { const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = await Git.run(["branch", "-D", branch], { cwd: Instance.worktree }) + const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree }) if (deleted.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) } @@ -540,7 +540,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -573,7 +573,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Worktree not found" }) } - const remoteList = await Git.run(["remote"], { cwd: Instance.worktree }) + const remoteList = await git(["remote"], { cwd: Instance.worktree }) if (remoteList.exitCode !== 0) { throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) } @@ -592,17 +592,17 @@ export namespace Worktree { : "" const remoteHead = remote - ? await Git.run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) + ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) : { exitCode: 1, stdout: undefined, stderr: undefined } const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" - const mainCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], { + const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree, }) - const masterCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/master"], { + const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree, }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" @@ -613,7 +613,7 @@ export namespace Worktree { } if (remoteBranch) { - const fetch = await Git.run(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) + const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } @@ -625,7 +625,7 @@ export namespace Worktree { const worktreePath = entry.path - const resetToTarget = await Git.run(["reset", "--hard", target], { cwd: worktreePath }) + const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } @@ -635,26 +635,26 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } - const update = await Git.run(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) + const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } - const subReset = await Git.run(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { + const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { cwd: worktreePath, }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } - const subClean = await Git.run(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { + const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { cwd: worktreePath, }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await Git.run(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts deleted file mode 100644 index 3427d8eb5a..0000000000 --- a/packages/opencode/test/git/git.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { $ } from "bun" -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { ManagedRuntime } from "effect" -import { GitEffect } from "../../src/git/effect" -import { tmpdir } from "../fixture/fixture" - -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" - -async function withGit(fn: (rt: ManagedRuntime.ManagedRuntime) => Promise) { - const rt = ManagedRuntime.make(GitEffect.defaultLayer) - try { - return await fn(rt) - } finally { - await rt.dispose() - } -} - -describe("Git", () => { - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) - - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeUndefined() - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.defaultBranch(tmp.path))) - expect(branch?.name).toBe("trunk") - expect(branch?.ref).toBe("trunk") - }) - }) - - test("status() handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withGit(async (rt) => { - const status = await rt.runPromise(GitEffect.Service.use((git) => git.status(tmp.path))) - expect(status).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff(), stats(), and mergeBase() parse tracked changes", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - const file = weird - await fs.writeFile(path.join(tmp.path, file), "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, file), "after\n", "utf-8") - - await withGit(async (rt) => { - const [base, diff, stats] = await Promise.all([ - rt.runPromise(GitEffect.Service.use((git) => git.mergeBase(tmp.path, "main"))), - rt.runPromise(GitEffect.Service.use((git) => git.diff(tmp.path, "HEAD"))), - rt.runPromise(GitEffect.Service.use((git) => git.stats(tmp.path, "HEAD"))), - ]) - - expect(base).toBeTruthy() - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "modified", - }), - ]), - ) - expect(stats).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - additions: 1, - deletions: 1, - }), - ]), - ) - }) - }) -}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e6588fb38e..a71fe0528f 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,48 +10,45 @@ import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) -const gitModule = await import("../../src/git") -const originalGit = gitModule.Git.run +const gitModule = await import("../../src/util/git") +const originalGit = gitModule.git type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" let mode: Mode = "none" -mock.module("../../src/git", () => ({ - Git: { - ...gitModule.Git, - run: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, +mock.module("../../src/util/git", () => ({ + git: (args: string[], opts: { cwd: string; env?: Record }) => { + const cmd = ["git", ...args].join(" ") + if ( + mode === "rev-list-fail" && + cmd.includes("git rev-list") && + cmd.includes("--max-parents=0") && + cmd.includes("HEAD") + ) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + return originalGit(args, opts) }, })) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c0967993fd..90f445ed78 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -23,7 +23,7 @@ function withVcs( ) { return withServices( directory, - Layer.merge(FileWatcher.layer, Vcs.defaultLayer), + Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(Vcs.Service.use(() => Effect.void)) @@ -34,15 +34,7 @@ function withVcs( ) } -function withVcsOnly( - directory: string, - body: (rt: ManagedRuntime.ManagedRuntime) => Promise, -) { - return withServices(directory, Vcs.defaultLayer, body) -} - type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ function nextBranchUpdate(directory: string, timeout = 10_000) { @@ -129,104 +121,3 @@ describeVcs("Vcs", () => { }) }) }) - -describe("Vcs diff", () => { - afterEach(() => Instance.disposeAll()) - - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("main") - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("trunk") - }) - }) - - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() - - await withVcsOnly(dir, async (rt) => { - const [branch, base] = await Promise.all([ - rt.runPromise(Vcs.Service.use((s) => s.branch())), - rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())), - ]) - expect(branch).toBe("feature/test") - expect(base).toBe("main") - }) - }) - - test("diff('git') returns uncommitted changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "file.txt", - status: "modified", - }), - ]), - ) - }) - }) - - test("diff('git') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("branch"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) -}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6ec934e6b8..aa759bb1e0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -172,7 +172,6 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, - VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -3662,38 +3661,6 @@ export class Vcs extends HeyApiClient { ...params, }) } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 942e1c3e13..41aa248171 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1889,8 +1889,7 @@ export type Path = { } export type VcsInfo = { - branch?: string - default_branch?: string + branch: string } export type Command = { @@ -4834,26 +4833,6 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 41819ef120..350395423f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6576,59 +6576,6 @@ ] } }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, "/command": { "get": { "operationId": "command.list", @@ -11981,11 +11928,9 @@ "properties": { "branch": { "type": "string" - }, - "default_branch": { - "type": "string" } - } + }, + "required": ["branch"] }, "Command": { "type": "object", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d9f724fce1..18823aeaa1 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,7 +1,5 @@ export const dict: Record = { "ui.sessionReview.title": "Session changes", - "ui.sessionReview.title.git": "Git changes", - "ui.sessionReview.title.branch": "Branch changes", "ui.sessionReview.title.lastTurn": "Last turn changes", "ui.sessionReview.diffStyle.unified": "Unified", "ui.sessionReview.diffStyle.split": "Split", From baa204193c02f1a4699ea2790df19b48e40440db Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 16:16:29 +0000 Subject: [PATCH 13/13] chore: generate --- packages/app/e2e/projects/projects-switch.spec.ts | 10 +++++++++- packages/app/src/pages/layout.tsx | 6 ++++-- packages/app/src/pages/layout/helpers.ts | 6 ++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 1416aec726..e9cbf868df 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,15 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" +import { + defocus, + createTestProject, + cleanupTestProject, + openSidebar, + sessionIDFromUrl, + waitDir, + waitSlug, +} from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cca14fd50c..52ac7c5f37 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -633,7 +633,8 @@ export default function Layout(props: ParentProps) { if (!expanded) continue const key = workspaceKey(directory) const project = projects.find( - (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => + workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -1163,7 +1164,8 @@ export default function Layout(props: ParentProps) { const project = layout.projects .list() .find( - (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => + workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (project) return project.worktree diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 886ffd26a1..209cff8a7c 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,11 +31,13 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) -export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], now: number) => + stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record,