From 08ca1237cc25634e2af97a62d036a48a80dc8d6e Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:04:20 -0500 Subject: [PATCH 001/205] fix(tui): Center the initially selected session in the session_list (resolves #8558) (#8560) --- .../src/cli/cmd/tui/ui/dialog-select.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index a0ab462a568..f7d7306d015 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -109,15 +109,16 @@ export function DialogSelect(props: DialogSelectProps) { createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { - if (filter.length > 0) { - setStore("selected", 0) - } else if (current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) - if (currentIndex >= 0) { - setStore("selected", currentIndex) + setTimeout(() => { + if (filter.length > 0) { + moveTo(0, true) + } else if (current) { + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) + if (currentIndex >= 0) { + moveTo(currentIndex, true) + } } - } - scroll?.scrollTo(0) + }, 0) }), ) @@ -129,7 +130,7 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next) } - function moveTo(next: number) { + function moveTo(next: number, center = false) { setStore("selected", next) props.onMove?.(selected()!) if (!scroll) return @@ -138,13 +139,18 @@ export function DialogSelect(props: DialogSelectProps) { }) if (!target) return const y = target.y - scroll.y - if (y >= scroll.height) { - scroll.scrollBy(y - scroll.height + 1) - } - if (y < 0) { - scroll.scrollBy(y) - if (isDeepEqual(flat()[0].value, selected()?.value)) { - scroll.scrollTo(0) + if (center) { + const centerOffset = Math.floor(scroll.height / 2) + scroll.scrollBy(y - centerOffset) + } else { + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } + if (y < 0) { + scroll.scrollBy(y) + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) + } } } } From 92931437c4ce48d2c4fdcad14067bff9a6f5d3ef Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:31:50 -0800 Subject: [PATCH 002/205] fix: codex id issue (#8605) --- packages/opencode/src/provider/transform.ts | 75 +++- packages/opencode/src/session/llm.ts | 9 +- .../opencode/test/provider/transform.test.ts | 411 ++++++++++++++---- 3 files changed, 391 insertions(+), 104 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4acc28fbfca..1dbc24db5df 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,7 +16,31 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { - function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + function normalizeMessages( + msgs: ModelMessage[], + model: Provider.Model, + options: Record, + ): ModelMessage[] { + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" || options.store === false) { + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return msg + const content = msg.content.map((part) => { + if (!part.providerOptions?.openai) return part + const { itemId, reasoningEncryptedContent, ...rest } = part.providerOptions.openai as Record + const openai = Object.keys(rest).length > 0 ? rest : undefined + return { + ...part, + providerOptions: { + ...part.providerOptions, + openai, + }, + } + }) + return { ...msg, content } as typeof msg + }) + } + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -218,9 +242,9 @@ export namespace ProviderTransform { }) } - export function message(msgs: ModelMessage[], model: Provider.Model) { + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) - msgs = normalizeMessages(msgs, model) + msgs = normalizeMessages(msgs, model, options) if ( model.providerID === "anthropic" || model.api.id.includes("anthropic") || @@ -453,64 +477,69 @@ export namespace ProviderTransform { return {} } - export function options( - model: Provider.Model, - sessionID: string, - providerOptions?: Record, - ): Record { + export function options(input: { + model: Provider.Model + sessionID: string + providerOptions?: Record + }): Record { const result: Record = {} - if (model.api.npm === "@openrouter/ai-sdk-provider") { + // openai and providers using openai package should set store to false by default. + if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") { + result["store"] = false + } + + if (input.model.api.npm === "@openrouter/ai-sdk-provider") { result["usage"] = { include: true, } - if (model.api.id.includes("gemini-3")) { + if (input.model.api.id.includes("gemini-3")) { result["reasoning"] = { effort: "high" } } } if ( - model.providerID === "baseten" || - (model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(model.api.id)) + input.model.providerID === "baseten" || + (input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id)) ) { result["chat_template_args"] = { enable_thinking: true } } - if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") { + if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") { result["thinking"] = { type: "enabled", clear_thinking: false, } } - if (model.providerID === "openai" || providerOptions?.setCacheKey) { - result["promptCacheKey"] = sessionID + if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { + result["promptCacheKey"] = input.sessionID } - if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") { + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { result["thinkingConfig"] = { includeThoughts: true, } - if (model.api.id.includes("gemini-3")) { + if (input.model.api.id.includes("gemini-3")) { result["thinkingConfig"]["thinkingLevel"] = "high" } } - if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) { - if (model.providerID.includes("codex")) { + if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { + if (input.model.providerID.includes("codex")) { result["store"] = false } - if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) { + if (!input.model.api.id.includes("codex") && !input.model.api.id.includes("gpt-5-pro")) { result["reasoningEffort"] = "medium" } - if (model.api.id.endsWith("gpt-5.") && model.providerID !== "azure") { + if (input.model.api.id.endsWith("gpt-5.") && input.model.providerID !== "azure") { result["textVerbosity"] = "low" } - if (model.providerID.startsWith("opencode")) { - result["promptCacheKey"] = sessionID + if (input.model.providerID.startsWith("opencode")) { + result["promptCacheKey"] = input.sessionID result["include"] = ["reasoning.encrypted_content"] result["reasoningSummary"] = "auto" } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ebc22637e10..5b6178bc01b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -95,7 +95,11 @@ export namespace LLM { !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} const base = input.small ? ProviderTransform.smallOptions(input.model) - : ProviderTransform.options(input.model, input.sessionID, provider.options) + : ProviderTransform.options({ + model: input.model, + sessionID: input.sessionID, + providerOptions: provider.options, + }) const options: Record = pipe( base, mergeDeep(input.model.options), @@ -104,7 +108,6 @@ export namespace LLM { ) if (isCodex) { options.instructions = SystemPrompt.instructions() - options.store = false } const params = await Plugin.trigger( @@ -214,7 +217,7 @@ export namespace LLM { async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model) + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) } return args.params }, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 32b1ecb2444..3814e9d99f9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -39,22 +39,34 @@ describe("ProviderTransform.options - setCacheKey", () => { } as any test("should set promptCacheKey when providerOptions.setCacheKey is true", () => { - const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: true }) + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: { setCacheKey: true }, + }) expect(result.promptCacheKey).toBe(sessionID) }) test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => { - const result = ProviderTransform.options(mockModel, sessionID, { setCacheKey: false }) + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) expect(result.promptCacheKey).toBeUndefined() }) test("should not set promptCacheKey when providerOptions is undefined", () => { - const result = ProviderTransform.options(mockModel, sessionID, undefined) + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: undefined, + }) expect(result.promptCacheKey).toBeUndefined() }) test("should not set promptCacheKey when providerOptions does not have setCacheKey", () => { - const result = ProviderTransform.options(mockModel, sessionID, {}) + const result = ProviderTransform.options({ model: mockModel, sessionID, providerOptions: {} }) expect(result.promptCacheKey).toBeUndefined() }) @@ -68,9 +80,27 @@ describe("ProviderTransform.options - setCacheKey", () => { npm: "@ai-sdk/openai", }, } - const result = ProviderTransform.options(openaiModel, sessionID, {}) + const result = ProviderTransform.options({ model: openaiModel, sessionID, providerOptions: {} }) expect(result.promptCacheKey).toBe(sessionID) }) + + test("should set store=false for openai provider", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) }) describe("ProviderTransform.maxOutputTokens", () => { @@ -208,40 +238,44 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, { - id: "deepseek/deepseek-chat", - providerID: "deepseek", - api: { - id: "deepseek-chat", - url: "https://api.deepseek.com", - npm: "@ai-sdk/openai-compatible", - }, - name: "DeepSeek Chat", - capabilities: { - temperature: true, - reasoning: true, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: { - field: "reasoning_content", + const result = ProviderTransform.message( + msgs, + { + id: "deepseek/deepseek-chat", + providerID: "deepseek", + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", }, + name: "DeepSeek Chat", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { + field: "reasoning_content", + }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", }, - cost: { - input: 0.001, - output: 0.002, - cache: { read: 0.0001, write: 0.0002 }, - }, - limit: { - context: 128000, - output: 8192, - }, - status: "active", - options: {}, - headers: {}, - release_date: "2023-04-01", - }) + {}, + ) expect(result).toHaveLength(1) expect(result[0].content).toEqual([ @@ -266,38 +300,42 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, { - id: "openai/gpt-4", - providerID: "openai", - api: { - id: "gpt-4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - name: "GPT-4", - capabilities: { - temperature: true, - reasoning: false, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { - input: 0.03, - output: 0.06, - cache: { read: 0.001, write: 0.002 }, - }, - limit: { - context: 128000, - output: 4096, + const result = ProviderTransform.message( + msgs, + { + id: "openai/gpt-4", + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: "GPT-4", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", }, - status: "active", - options: {}, - headers: {}, - release_date: "2023-04-01", - }) + {}, + ) expect(result[0].content).toEqual([ { type: "reasoning", text: "Should not be processed" }, @@ -351,7 +389,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel) + const result = ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -375,7 +413,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel) + const result = ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -397,7 +435,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel) + const result = ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(3) @@ -450,7 +488,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -469,7 +507,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) @@ -488,7 +526,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) @@ -508,7 +546,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") @@ -526,7 +564,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) @@ -550,7 +588,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel) + const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -577,7 +615,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, openaiModel) + const result = ProviderTransform.message(msgs, openaiModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("") @@ -585,6 +623,223 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) +describe("ProviderTransform.message - strip openai metadata when store=false", () => { + const openaiModel = { + id: "openai/gpt-5", + providerID: "openai", + api: { + id: "gpt-5", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: "GPT-5", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.03, output: 0.06, cache: { read: 0.001, write: 0.002 } }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("strips itemId and reasoningEncryptedContent when store=false", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { + itemId: "rs_123", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_456", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { + const zenModel = { + ...openaiModel, + providerID: "zen", + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { + itemId: "rs_123", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_456", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("preserves other openai options when stripping itemId", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + otherOption: "value", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") + }) + + test("strips metadata for openai package even when store is true", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // openai package always strips itemId regardless of store value + const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("strips metadata for non-openai packages when store is false", () => { + const anthropicModel = { + ...openaiModel, + providerID: "anthropic", + api: { + id: "claude-3", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // store=false triggers stripping even for non-openai packages + const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("does not strip metadata for non-openai packages when store is not false", () => { + const anthropicModel = { + ...openaiModel, + providerID: "anthropic", + api: { + id: "claude-3", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", From 8d720f9463e3d838064acc6a55055c76e2494fa2 Mon Sep 17 00:00:00 2001 From: Brandon Smith <4730164+brandon93s@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:35:16 -0600 Subject: [PATCH 003/205] fix(opencode): add input limit for compaction (#8465) --- packages/opencode/src/plugin/codex.ts | 32 -------------- packages/opencode/src/provider/models.ts | 1 + packages/opencode/src/provider/provider.ts | 2 + packages/opencode/src/session/compaction.ts | 2 +- .../opencode/test/session/compaction.test.ts | 44 ++++++++++++++++++- 5 files changed, 47 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 91e66197fc4..fc172dad939 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -361,38 +361,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } } - if (!provider.models["gpt-5.2-codex"]) { - const model = { - id: "gpt-5.2-codex", - providerID: "openai", - api: { - id: "gpt-5.2-codex", - url: "https://chatgpt.com/backend-api/codex", - npm: "@ai-sdk/openai", - }, - name: "GPT-5.2 Codex", - capabilities: { - temperature: false, - reasoning: true, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 400000, output: 128000 }, - status: "active" as const, - options: {}, - headers: {}, - release_date: "2025-12-18", - variants: {} as Record>, - family: "gpt-codex", - } - model.variants = ProviderTransform.variants(model) - provider.models["gpt-5.2-codex"] = model - } - // Zero out costs for Codex (included with ChatGPT subscription) for (const model of Object.values(provider.models)) { model.cost = { diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 796dcb7c238..5aedce505cd 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -47,6 +47,7 @@ export namespace ModelsDev { .optional(), limit: z.object({ context: z.number(), + input: z.number().optional(), output: z.number(), }), modalities: z diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 69946afd83a..9e2dd0ba0b5 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -557,6 +557,7 @@ export namespace Provider { }), limit: z.object({ context: z.number(), + input: z.number().optional(), output: z.number(), }), status: z.enum(["alpha", "beta", "deprecated", "active"]), @@ -619,6 +620,7 @@ export namespace Provider { }, limit: { context: model.limit.context, + input: model.limit.input, output: model.limit.output, }, capabilities: { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 42bab2eb975..ae69221288f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -34,7 +34,7 @@ export namespace SessionCompaction { if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX - const usable = context - output + const usable = input.model.limit.input || context - output return count > usable } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9070428ea54..2e9c091870e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,13 +10,19 @@ import type { Provider } from "../../src/provider/provider" Log.init({ print: false }) -function createModel(opts: { context: number; output: number; cost?: Provider.Model["cost"] }): Provider.Model { +function createModel(opts: { + context: number + output: number + input?: number + cost?: Provider.Model["cost"] +}): Provider.Model { return { id: "test-model", providerID: "test", name: "Test", limit: { context: opts.context, + input: opts.input, output: opts.output, }, cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, @@ -70,6 +76,42 @@ describe("session.compaction.isOverflow", () => { }) }) + test("respects input limit for input caps", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) + const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true) + }, + }) + }) + + test("returns false when input/output are within input caps", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) + const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false) + }, + }) + }) + + test("returns false when output within limit with input caps", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = createModel({ context: 200_000, input: 120_000, output: 10_000 }) + const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } } + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false) + }, + }) + }) + test("returns false when model context limit is 0", async () => { await using tmp = await tmpdir() await Instance.provide({ From 972f5ecc7d107f7409d11fef06d95fe0bdf5a3ad Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 15 Jan 2026 07:35:52 +0000 Subject: [PATCH 004/205] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ packages/sdk/openapi.json | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e810e0bf5b0..1ffc60e6a80 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1426,6 +1426,7 @@ export type ProviderConfig = { } limit?: { context: number + input?: number output: number } modalities?: { @@ -1919,6 +1920,7 @@ export type Model = { } limit: { context: number + input?: number output: number } status: "alpha" | "beta" | "deprecated" | "active" @@ -3824,6 +3826,7 @@ export type ProviderListResponses = { } limit: { context: number + input?: number output: number } modalities?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0c2e640fc4a..02ba8d89ac1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3572,6 +3572,9 @@ "context": { "type": "number" }, + "input": { + "type": "number" + }, "output": { "type": "number" } @@ -8868,6 +8871,9 @@ "context": { "type": "number" }, + "input": { + "type": "number" + }, "output": { "type": "number" } @@ -9998,6 +10004,9 @@ "context": { "type": "number" }, + "input": { + "type": "number" + }, "output": { "type": "number" } From 1fb611ef0ad33f0bb9bc6865ec3c67521a95d8ad Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Thu, 15 Jan 2026 03:09:23 -0500 Subject: [PATCH 005/205] fix: enable sticky header on changelog and download pages (#8556) --- packages/console/app/src/routes/changelog/index.css | 1 - packages/console/app/src/routes/download/index.css | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css index 277dc624f00..29020a924e5 100644 --- a/packages/console/app/src/routes/changelog/index.css +++ b/packages/console/app/src/routes/changelog/index.css @@ -32,7 +32,6 @@ font-family: var(--font-mono); color: var(--color-text); padding-bottom: 5rem; - overflow-x: hidden; @media (prefers-color-scheme: dark) { --color-background: hsl(0, 9%, 7%); diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css index 5178a6e55b9..a5ca692a2d5 100644 --- a/packages/console/app/src/routes/download/index.css +++ b/packages/console/app/src/routes/download/index.css @@ -34,7 +34,6 @@ font-family: var(--font-mono); color: var(--color-text); padding-bottom: 5rem; - overflow-x: hidden; @media (prefers-color-scheme: dark) { --color-background: hsl(0, 9%, 7%); From 779610d668e239c07014c9d350d47e2213c07b26 Mon Sep 17 00:00:00 2001 From: Turcu Laurentiu Date: Thu, 15 Jan 2026 09:12:27 +0100 Subject: [PATCH 006/205] fix(desktop): open external links in system browser instead of webview (#7360) --- packages/desktop/src/index.tsx | 9 +++++++++ packages/ui/src/context/marked.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5d699bb90c5..f05a28e1488 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -292,6 +292,15 @@ root?.addEventListener("mousewheel", (e) => { e.stopPropagation() }) +// Handle external links - open in system browser instead of webview +document.addEventListener("click", (e) => { + const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null + if (link?.href) { + e.preventDefault() + platform.openLink(link.href) + } +}) + render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 9bd48c9a9bc..6cf1dd54e8d 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -383,7 +383,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( renderer: { link({ href, title, text }) { const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + return `${text}` }, }, }, From 9f66a45970d1edf12ae9b3e7a22d77711b5e51c3 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:38:29 -0600 Subject: [PATCH 007/205] feat(app): new layout --- .../src/components/session/session-header.tsx | 402 +++++---- packages/app/src/context/global-sync.tsx | 9 +- packages/app/src/context/platform.tsx | 3 + packages/app/src/pages/layout.tsx | 842 +++++++++--------- packages/desktop/src/index.tsx | 31 +- 5 files changed, 657 insertions(+), 630 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cfc6eb4387b..b2e7fafeb39 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,5 @@ import { createMemo, createResource, Show } from "solid-js" +import { Portal } from "solid-js/web" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" @@ -57,211 +58,218 @@ export function SessionHeader() { navigate(`/${params.dir}/session/${session.id}`) } + const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + return ( -
- -
-
-
- - + <> + + {(mount) => ( + +
+
+ +
- -
- - - -
-
- -
- -
+ + )} + + + {(mount) => ( + +
+ - - - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) - } - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } - - ) - })} - - -
-
-
+ + {server.name} + + + + +
+ + + + +
+ + + + + } + > + {iife(() => { + const [url] = createResource( + () => currentSession(), + async (session) => { + if (!session) return + let shareURL = session.share?.url + if (!shareURL) { + shareURL = await globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) + } + return shareURL + }, + { initialValue: "" }, + ) + return ( + + {(shareUrl) => } + + ) + })} + + + + + )} + + ) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ea0b90d5de7..a0b25705686 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -124,12 +124,19 @@ function createGlobalSync() { return globalSDK.client.session .list({ directory, roots: true }) .then((x) => { - const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .slice() .sort((a, b) => a.id.localeCompare(b.id)) + + const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) + if (sandboxWorkspace) { + setStore("session", reconcile(nonArchived, { key: "id" })) + return + } + + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 // Include up to the limit, plus any updated in the last 4 hours const sessions = nonArchived.filter((s, i) => { if (i < limit) return true diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b0822e70787..6d2d3db0602 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,9 @@ export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" + /** Desktop OS (Tauri only) */ + os?: "macos" | "windows" | "linux" + /** App version */ version?: string diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 39f397ac466..535f4aef2e4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,6 @@ import { untrack, type JSX, } from "solid-js" -import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -27,9 +26,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" -import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" @@ -53,7 +50,6 @@ import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -64,16 +60,12 @@ import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, - activeDraggable: undefined as string | undefined, - mobileProjectsExpanded: {} as Record, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceExpanded: {} as Record, }) - const mobileProjects = { - expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, - expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), - collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), - } - let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) @@ -275,6 +267,31 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) + createEffect(() => { + const project = currentProject() + if (!project) return + + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) { + setStore("workspaceOrder", project.worktree, dirs) + return + } + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + const merged = [...keep, ...missing] + + if (merged.length !== existing.length) { + setStore("workspaceOrder", project.worktree, merged) + return + } + + if (merged.some((d, i) => d !== existing[i])) { + setStore("workspaceOrder", project.worktree, merged) + } + }) + function projectSessions(project: LocalProject | undefined) { if (!project) return [] const dirs = [project.worktree, ...(project.sandboxes ?? [])] @@ -325,7 +342,7 @@ export default function Layout(props: ParentProps) { return created } - const prefetchMessages = (directory: string, sessionID: string, token: number) => { + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) @@ -704,18 +721,17 @@ export default function Layout(props: ParentProps) { const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) - const project = currentProject() - untrack(() => layout.projects.expand(project?.worktree ?? directory)) + untrack(() => setStore("workspaceExpanded", directory, true)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { if (isLargeViewport()) { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64 document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) - } else { - document.documentElement.style.setProperty("--dialog-left-margin", "0px") + return } + document.documentElement.style.setProperty("--dialog-left-margin", "0px") }) function getDraggableId(event: unknown): string | undefined { @@ -729,7 +745,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setStore("activeDraggable", id) + setStore("activeProject", id) } function handleDragOver(event: DragEvent) { @@ -745,44 +761,73 @@ export default function Layout(props: ParentProps) { } function handleDragEnd() { - setStore("activeDraggable", undefined) + setStore("activeProject", undefined) + } + + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) return dirs + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + return [...keep, ...missing] + } + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = currentProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore("workspaceOrder", project.worktree, result) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) } - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( -
+
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } /> - - 0 && props.notify}>
{ - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const current = createMemo(() => base64Decode(params.dir ?? "")) - return ( - - - - - - - - - ) - } - - const SessionItem = (props: { - session: Session - slug: string - project: LocalProject - mobile?: boolean - }): JSX.Element => { + const SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): JSX.Element => { const notification = useNotification() - const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const [sessionStore] = globalSync.child(props.session.directory) @@ -852,294 +858,243 @@ export default function Layout(props: ParentProps) { const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + return ( - <> -
- - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
- - {props.session.title} - -
- - - - - -
- - -
- - 0}> -
- - - - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - - - -
-
- -
- {`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`} - {(summary) => } -
-
-
- - + {props.session.title} + +
+ + + + + +
+ + +
+ + 0}> +
+ + + {(summary) => } +
+
+ + - +
) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) - const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) - const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const [store, setProjectStore] = globalSync.child(props.project.worktree) - const stores = createMemo(() => - [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), - ) - const sessions = createMemo(() => - stores() - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions), - ) - const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) - const hasMoreSessions = createMemo(() => store.sessionTotal > store.session.length) - const loadMoreSessions = async () => { - setProjectStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.project.worktree) - } - const isExpanded = createMemo(() => - props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, - ) - const isActive = createMemo(() => { + const selected = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) - const handleOpenChange = (open: boolean) => { - if (props.mobile) { - if (open) mobileProjects.expand(props.project.worktree) - else mobileProjects.collapse(props.project.worktree) - } else { - if (open) layout.projects.expand(props.project.worktree) - else layout.projects.collapse(props.project.worktree) - } - } + return ( // @ts-ignore
- - - - - - - - - - - - - - - + + +
) } const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) return ( {(p) => ( -
- +
+
)} ) } - const SidebarContent = (sidebarProps: { mobile?: boolean }) => { - const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + const WorkspaceDragOverlay = (): JSX.Element => { + const label = createMemo(() => { + const project = currentProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory) + const kind = directory === project.worktree ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name}` + }) + return ( -
-
- -
- - - + + {(value) => ( +
{value()}
+ )} +
+ ) + } + + const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const local = createMemo(() => props.directory === props.project.worktree) + const title = createMemo(() => { + const kind = local() ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) + return `${kind} : ${name}` + }) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) + const hasMore = createMemo(() => local() && workspaceStore.session.length >= workspaceStore.limit) + const loadMore = async () => { + if (!local()) return + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + return ( + // @ts-ignore +
+ setStore("workspaceExpanded", props.directory, value)} + > + +
+ + {title()}
- -
- - + + + + +
+ ) + } + + const SidebarContent = (sidebarProps: { mobile?: boolean }) => { + const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + + const project = createMemo(() => currentProject()) + const projectName = createMemo(() => { + const current = project() + if (!current) return "" + return current.name || getFilename(current.worktree) + }) + const workspaces = createMemo(() => workspaceIds(project())) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return "Request failed" + } + + const createWorkspace = async () => { + const current = project() + if (!current) return + + const created = await globalSDK.client.worktree + .create({ directory: current.worktree }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create workspace", + description: errorMessage(err), + }) + return undefined + }) + + if (!created?.directory) return + + globalSync.child(created.directory) + navigate(`/${base64Encode(created.directory)}/session`) + } + + return ( +
+
+
-
{ - if (!sidebarProps.mobile) scrollContainerRef = el - }} - class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" - > +
p.worktree)}> {(project) => } + + Open project + + {command.keybind("project.open")} + +
+ } + > + +
@@ -1166,29 +1129,61 @@ export default function Layout(props: ParentProps) {
-
- - 0 && !providers.paid().length && expanded()}> -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- - - -
-
- 0}> - + + +
+ + {(p) => ( + <> +
+
{projectName()}
+ +
+ +
+ + + +
{ + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="h-full w-full flex flex-col gap-2 px-2 py-2 overflow-y-auto no-scrollbar" + > + + + {(directory) => ( + + )} + + +
+ + + +
+
+ + )} +
+ +
Open a project to see workspaces.
+
+ 0}> +
+ - - - - - Open project - - {command.keybind("project.open")} -
- } - inactive={expanded()} - > - - - - - -
+
+
+
) } + const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserveWindowButtons = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + return (
+
+
+ +
+ + + +
+
+
+ +
+ +
+
-
+
@@ -1285,21 +1292,12 @@ export default function Layout(props: ParentProps) { />
e.stopPropagation()} > -
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index f05a28e1488..e554f8da0cf 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Logo } from "@opencode-ai/ui/logo" -import { createSignal, Show, Accessor, JSX, createResource } from "solid-js" +import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" @@ -30,6 +30,11 @@ let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ platform: "desktop", + os: (() => { + const type = ostype() + if (type === "macos" || type === "windows" || type === "linux") return type + return undefined + })(), version: pkg.version, async openDirectoryPickerDialog(opts) { @@ -292,19 +297,25 @@ root?.addEventListener("mousewheel", (e) => { e.stopPropagation() }) -// Handle external links - open in system browser instead of webview -document.addEventListener("click", (e) => { - const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null - if (link?.href) { - e.preventDefault() - platform.openLink(link.href) - } -}) - render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) + function handleClick(e: MouseEvent) { + const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null + if (link?.href) { + e.preventDefault() + platform.openLink(link.href) + } + } + + onMount(() => { + document.addEventListener("click", handleClick) + onCleanup(() => { + document.removeEventListener("click", handleClick) + }) + }) + return ( From 679270d9e0731c2b3e2c059d83907cb4086d90e2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:11:29 -0600 Subject: [PATCH 008/205] feat(app): new layout --- packages/app/index.html | 1 - .../src/components/session/session-header.tsx | 101 +--- packages/app/src/components/titlebar.tsx | 115 ++++ packages/app/src/context/layout.tsx | 8 + packages/app/src/context/sync.tsx | 2 +- packages/app/src/index.css | 4 + packages/app/src/pages/layout.tsx | 523 ++++++++++++------ packages/app/src/pages/session.tsx | 19 +- packages/desktop/index.html | 2 +- packages/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 1 + packages/desktop/src-tauri/src/lib.rs | 45 +- packages/desktop/src-tauri/tauri.conf.json | 14 + .../desktop/src-tauri/tauri.prod.conf.json | 21 + packages/ui/src/components/hover-card.css | 55 ++ packages/ui/src/components/hover-card.tsx | 31 ++ packages/ui/src/components/icon.tsx | 6 +- packages/ui/src/components/spinner.tsx | 7 +- packages/ui/src/styles/index.css | 1 + 19 files changed, 681 insertions(+), 276 deletions(-) create mode 100644 packages/app/src/components/titlebar.tsx create mode 100644 packages/ui/src/components/hover-card.css create mode 100644 packages/ui/src/components/hover-card.tsx diff --git a/packages/app/index.html b/packages/app/index.html index e0fbe6913df..a8d663454f8 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -13,7 +13,6 @@ - diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b2e7fafeb39..5ed72174041 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -34,6 +34,17 @@ export function SessionHeader() { const sync = useSync() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) @@ -58,87 +69,29 @@ export function SessionHeader() { navigate(`/${params.dir}/session/${session.id}`) } - const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left")) + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( <> - + {(mount) => ( -
-
- - - x.title} - value={(x) => x.id} - onSelect={(session) => { - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } - }} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" - variant="ghost" - /> -
/
- - - -
- -
- - +
+ )} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx new file mode 100644 index 00000000000..5cf9f74bc99 --- /dev/null +++ b/packages/app/src/components/titlebar.tsx @@ -0,0 +1,115 @@ +import { createEffect, createMemo, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme" + +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" + +export function Titlebar() { + const layout = useLayout() + const platform = usePlatform() + const command = useCommand() + const theme = useTheme() + + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserve = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + + const getWin = () => { + if (platform.platform !== "desktop") return + + const tauri = ( + window as unknown as { + __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + } + ).__TAURI__ + if (!tauri?.window?.getCurrentWindow) return + + return tauri.window.getCurrentWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) + .__TAURI__ + const get = tauri?.webviewWindow?.getCurrentWebviewWindow + if (!get) return + + const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise } + if (!win.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + return ( +
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa57..ba332be7b10 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -53,6 +53,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sidebar: { opened: false, width: 280, + workspaces: false, }, terminal: { height: 280, @@ -304,6 +305,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, + workspaces: createMemo(() => store.sidebar.workspaces ?? false), + setWorkspaces(value: boolean) { + setStore("sidebar", "workspaces", value) + }, + toggleWorkspaces() { + setStore("sidebar", "workspaces", (x) => !x) + }, }, terminal: { height: createMemo(() => store.terminal.height), diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index e5f2c076ed8..33129e1b475 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - const chunk = 200 + const chunk = 400 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b15..d9d51aa8fbf 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,7 @@ cursor: default; } } + +*[data-tauri-drag-region] { + app-region: drag; +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 535f4aef2e4..cc539665614 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -23,6 +23,8 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -55,6 +57,8 @@ import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { @@ -814,20 +818,24 @@ export default function Layout(props: ParentProps) { const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( -
- 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined - } - /> +
+
+ 0 && props.notify + ? { "-webkit-mask-image": mask, "mask-image": mask } + : undefined + } + /> +
0 && props.notify}>
{ + const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) @@ -859,47 +867,62 @@ export default function Layout(props: ParentProps) { return status?.type === "busy" || status?.type === "retry" }) + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + const user = messages + .slice() + .reverse() + .find((m) => m.role === "user") + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agent?.color + }) + return (
prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} > - - {props.session.title} - -
- - - - - -
- - -
- - 0}> -
- - +
+
+ + + + + +
+ + +
+ + 0}> +
+ + +
+ + {props.session.title} + {(summary) => }
-