From 6f8e1dda15ea24f33e06ba959a8d5278b8f1ac8b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:12:07 -0500 Subject: [PATCH 01/38] fix(provider): derive variants from reasoning options (#36543) --- packages/core/src/models-dev.ts | 3 ++ packages/core/test/models.test.ts | 19 +++++++- packages/opencode/src/provider/provider.ts | 48 +++++++++++++++++++ .../opencode/test/provider/provider.test.ts | 48 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c2325cb57f..82582afdc3 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -51,6 +51,9 @@ export const Model = Schema.Struct({ release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, + // models.dev is external metadata and reasoning controls are expected to evolve. + // Provider normalization extracts the subset understood by this client. + reasoning_options: Schema.optional(Schema.Unknown), temperature: Schema.Boolean, tool_call: Schema.Boolean, interleaved: Schema.optional( diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8c..21e0f5653c 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref } from "effect" +import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test" +import { Effect, Layer, Ref, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" @@ -126,6 +126,21 @@ const initialState: MockState = { calls: [], } +test("models.dev model schema keeps reasoning options permissive", () => { + const model = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: [{ type: "future_control", value: { nested: true } }, "future-shape"], + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + expect(model.reasoning_options).toEqual([{ type: "future_control", value: { nested: true } }, "future-shape"]) +}) + describe("ModelsDev Service", () => { it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c0a222d649..b1263dcd6e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -981,6 +981,21 @@ const ProviderInterleaved = Schema.Union([ }), ]) +const ProviderReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: optional(Schema.Finite), + max: optional(Schema.Finite), + }), +]) + const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, @@ -1039,6 +1054,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, + reasoning_options: optional(Schema.Array(ProviderReasoningOption)), variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> @@ -1202,6 +1218,36 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +type ReasoningOption = NonNullable[number] + +function reasoningOptions(input: unknown): Model["reasoning_options"] { + if (!Array.isArray(input)) return [] + return input.flatMap((option) => { + const normalized = normalizeReasoningOption(option) + return normalized ? [normalized] : [] + }) +} + +function normalizeReasoningOption(option: unknown): ReasoningOption | undefined { + if (!isRecord(option)) return + if (option.type === "effort") { + if (!Array.isArray(option.values)) return + return { + type: "effort", + values: option.values.filter((value): value is string | null => value === null || typeof value === "string"), + } + } + if (option.type === "toggle") return { type: "toggle" } + if (option.type !== "budget_tokens") return + const min = typeof option.min === "number" && Number.isFinite(option.min) ? option.min : undefined + const max = typeof option.max === "number" && Number.isFinite(option.max) ? option.max : undefined + return { + type: "budget_tokens", + ...(min === undefined ? {} : { min }), + ...(max === undefined ? {} : { max }), + } +} + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1244,6 +1290,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model interleaved: model.interleaved ?? false, }, release_date: model.release_date ?? "", + reasoning_options: reasoningOptions(model.reasoning_options), variants: {}, } @@ -1490,6 +1537,7 @@ const layer = Layer.effect( headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), family: model.family ?? existingModel?.family ?? "", release_date: model.release_date ?? existingModel?.release_date ?? "", + reasoning_options: existingModel?.reasoning_options, variants: {}, } const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index be1c090674..b5f8b8ecf8 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1426,6 +1426,54 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("models.dev reasoning options normalize to known shapes", () => { + const provider = Provider.fromModelsDevProvider({ + id: "openai", + name: "OpenAI", + env: [], + npm: "@ai-sdk/openai", + models: { + reasoner: { + id: "reasoner", + name: "Reasoner", + reasoning: true, + reasoning_options: [ + { type: "future_control", value: true }, + { type: "effort", values: ["high", null, 42] }, + { type: "toggle" }, + { type: "budget_tokens", min: 1024, max: "invalid" }, + ], + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + + expect(provider.models.reasoner.reasoning_options).toEqual([ + { type: "effort", values: ["high", null] }, + { type: "toggle" }, + { type: "budget_tokens", min: 1024 }, + ]) +}) + +test("models.dev models without reasoning options normalize to an empty list", () => { + const provider = Provider.fromModelsDevProvider({ + id: "openai", + name: "OpenAI", + env: [], + npm: "@ai-sdk/openai", + models: { + reasoner: { + id: "gpt-5.4", + name: "Reasoner", + reasoning: true, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + + expect(provider.models.reasoner.reasoning_options).toEqual([]) +}) + test("public provider info omits invalid models", () => { const provider = Provider.fromModelsDevProvider({ id: "test", From cf7503687a2485621a690d18c4b0d1ff2060bc3e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 12 Jul 2026 21:13:21 +0000 Subject: [PATCH 02/38] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 14 +++++++ packages/sdk/openapi.json | 52 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5e067f3afb..38c2e1a2b4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2100,6 +2100,20 @@ export type Model = { [key: string]: string } release_date: string + reasoning_options?: Array< + | { + type: "effort" + values: Array + } + | { + type: "toggle" + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > variants?: { [key: string]: { [key: string]: unknown diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d97885b553..8fc81713bc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21777,6 +21777,58 @@ "release_date": { "type": "string" }, + "reasoning_options": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["effort"] + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "values"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["toggle"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["budget_tokens"] + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, "variants": { "type": "object", "additionalProperties": { From 8036440693eb3845fee14773f6044b6be0f0fedc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:41:02 -0500 Subject: [PATCH 03/38] ci: remove starptech from core triage assignees (#36618) Co-authored-by: Aiden Cline --- .opencode/tool/github-triage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index f25ca48b38..e861e1e467 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" const TEAM = { tui: ["kommander", "simonklee"], desktop_web: ["Hona", "Brendonovich"], - core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const From 8168f0f0f6645a0ca741fe02e90ff532bce04148 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:08:08 -0500 Subject: [PATCH 04/38] fix(provider): route gateway variants by api id (#36614) --- packages/opencode/src/provider/transform.ts | 8 ++--- .../opencode/test/provider/transform.test.ts | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 20c73d0216..7a6f192164 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -759,7 +759,7 @@ export function variants(model: Provider.Model): Record [ @@ -792,8 +792,8 @@ export function variants(model: Provider.Model): Record { }) describe("@ai-sdk/gateway", () => { + test("configured anthropic aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-claude", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("configured google aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-gemini", + providerID: "gateway", + api: { + id: "google/gemini-2.5-pro", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }) + }) + test("anthropic sonnet 4.6 models return adaptive thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4-6", From f47684787ad07e5311bd9422c1a9eb70c9966274 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:23:28 -0500 Subject: [PATCH 05/38] fix(opencode): filter unsupported GPT-5.6 OAuth alias (#36621) --- packages/opencode/src/plugin/openai/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 5a9cd6e859..ea9bba4344 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -285,6 +285,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug .filter(([, model]) => { if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false + if (model.api.id === "gpt-5.6") return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) From a8062ea314fb760723c551d786f5bfcd45ed733f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:21:59 -0500 Subject: [PATCH 06/38] fix(provider): derive variants from reasoning metadata (#36624) --- packages/core/src/models-dev.ts | 19 +- packages/core/test/models.test.ts | 19 +- packages/opencode/src/provider/provider.ts | 60 +---- packages/opencode/src/provider/transform.ts | 185 ++++++++++++++ .../opencode/test/provider/provider.test.ts | 123 ++++++--- .../opencode/test/provider/transform.test.ts | 237 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 14 -- packages/sdk/openapi.json | 52 ---- 8 files changed, 534 insertions(+), 175 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 82582afdc3..602133d904 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -44,6 +44,21 @@ const Cost = Schema.Struct({ ), }) +const ReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -51,11 +66,9 @@ export const Model = Schema.Struct({ release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, - // models.dev is external metadata and reasoning controls are expected to evolve. - // Provider normalization extracts the subset understood by this client. - reasoning_options: Schema.optional(Schema.Unknown), temperature: Schema.Boolean, tool_call: Schema.Boolean, + reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 21e0f5653c..a3ed86dc8c 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test" -import { Effect, Layer, Ref, Schema } from "effect" +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" @@ -126,21 +126,6 @@ const initialState: MockState = { calls: [], } -test("models.dev model schema keeps reasoning options permissive", () => { - const model = Schema.decodeUnknownSync(ModelsDev.Model)({ - id: "acme-1", - name: "Acme One", - release_date: "2026-01-01", - attachment: false, - reasoning: true, - reasoning_options: [{ type: "future_control", value: { nested: true } }, "future-shape"], - temperature: true, - tool_call: true, - limit: { context: 128000, output: 8192 }, - }) - expect(model.reasoning_options).toEqual([{ type: "future_control", value: { nested: true } }, "future-shape"]) -}) - describe("ModelsDev Service", () => { it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b1263dcd6e..69e29d3930 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -981,21 +981,6 @@ const ProviderInterleaved = Schema.Union([ }), ]) -const ProviderReasoningOption = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("effort"), - values: Schema.Array(Schema.NullOr(Schema.String)), - }), - Schema.Struct({ - type: Schema.Literal("toggle"), - }), - Schema.Struct({ - type: Schema.Literal("budget_tokens"), - min: optional(Schema.Finite), - max: optional(Schema.Finite), - }), -]) - const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, @@ -1054,7 +1039,6 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - reasoning_options: optional(Schema.Array(ProviderReasoningOption)), variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> @@ -1218,36 +1202,6 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } -type ReasoningOption = NonNullable[number] - -function reasoningOptions(input: unknown): Model["reasoning_options"] { - if (!Array.isArray(input)) return [] - return input.flatMap((option) => { - const normalized = normalizeReasoningOption(option) - return normalized ? [normalized] : [] - }) -} - -function normalizeReasoningOption(option: unknown): ReasoningOption | undefined { - if (!isRecord(option)) return - if (option.type === "effort") { - if (!Array.isArray(option.values)) return - return { - type: "effort", - values: option.values.filter((value): value is string | null => value === null || typeof value === "string"), - } - } - if (option.type === "toggle") return { type: "toggle" } - if (option.type !== "budget_tokens") return - const min = typeof option.min === "number" && Number.isFinite(option.min) ? option.min : undefined - const max = typeof option.max === "number" && Number.isFinite(option.max) ? option.max : undefined - return { - type: "budget_tokens", - ...(min === undefined ? {} : { min }), - ...(max === undefined ? {} : { max }), - } -} - function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1290,13 +1244,14 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model interleaved: model.interleaved ?? false, }, release_date: model.release_date ?? "", - reasoning_options: reasoningOptions(model.reasoning_options), variants: {}, } + const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base) + return { ...base, - variants: mapValues(ProviderTransform.variants(base), (v) => v), + variants: mapValues(variants, (v) => v), } } @@ -1537,10 +1492,13 @@ const layer = Layer.effect( headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), family: model.family ?? existingModel?.family ?? "", release_date: model.release_date ?? existingModel?.release_date ?? "", - reasoning_options: existingModel?.reasoning_options, variants: {}, } - const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) + const variants = + existingModel?.api.npm === parsedModel.api.npm + ? (existingModel.variants ?? ProviderTransform.variants(parsedModel)) + : ProviderTransform.variants(parsedModel) + const merged = mergeDeep(variants, model.variants ?? {}) parsedModel.variants = mapValues( pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]), @@ -1668,7 +1626,7 @@ const layer = Layer.effect( ) delete provider.models[modelID] - if (!model.variants || Object.keys(model.variants).length === 0) { + if (model.variants === undefined) { model.variants = mapValues(ProviderTransform.variants(model), (v) => v) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7a6f192164..73caf5ab6b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1578,4 +1578,189 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 return schema } +export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] { + const options = model.reasoning_options + if (options === undefined) return + if (options.length === 0) return {} + + const effort = options.find((option) => option.type === "effort") + if (effort) return nonEmptyVariants(effortVariants(target, effort.values)) + + const toggle = options.some((option) => option.type === "toggle") + const budget = options.find((option) => option.type === "budget_tokens") + if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined + + return nonEmptyVariants({ + ...(toggle ? reasoningToggle(target) : {}), + ...budgetVariants(target, budget.min, budget.max), + }) +} + +function effortVariants(model: Provider.Model, values: readonly unknown[]) { + return Object.fromEntries( + values.flatMap((value) => { + const id = (() => { + if (value === null) return "none" + if (typeof value === "string") return value + })() + if (id === undefined) return [] + const settings = reasoningEffort(model, id) + return settings ? [[id, settings]] : [] + }), + ) +} + +function budgetVariants(model: Provider.Model, min?: number, max?: number) { + const limit = model.limit.output - 1 + if (limit <= 0) return {} + const high = Math.min( + max === undefined ? Math.max(min ?? 0, 16_000) : Math.min(Math.max(min ?? 0, 16_000), max), + limit, + ) + const maximum = max === undefined ? undefined : Math.min(max, limit) + return Object.fromEntries( + [ + { id: "high", budget: high }, + ...(maximum === undefined || maximum === high ? [] : [{ id: "max", budget: maximum }]), + ].flatMap((item) => { + const settings = reasoningBudget(model, item.budget) + return settings ? [[item.id, settings]] : [] + }), + ) +} + +function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] { + return Object.keys(variants).length > 0 ? variants : undefined +} + +function reasoningToggle(model: Provider.Model): NonNullable { + if (model.api.npm === "@ai-sdk/alibaba") + return { + none: { enableThinking: false }, + high: { enableThinking: true }, + } + if (model.api.npm === "@ai-sdk/cohere") + return { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + } + return {} +} + +function reasoningEffort(model: Provider.Model, effort: string) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { effort } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return anthropicEffort(model, effort) + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + case "@ai-sdk/amazon-bedrock": + if (anthropicAdaptiveEfforts(model.api.id)) + return { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + } + if (model.api.id.includes("anthropic")) return + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + return { reasoningEffort: effort } + case "@ai-sdk/github-copilot": + // OAuth discovery replaces these with variants from Copilot's /models capabilities. + if (model.id.includes("gemini")) return + if (model.id.includes("claude")) return { reasoningEffort: effort } + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/openai": + case "@ai-sdk/amazon-bedrock/mantle": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/azure": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } } + return { modelParams: { reasoning_effort: effort } } + case "@ai-sdk/openai-compatible": + case "@ai-sdk/xai": + case "@ai-sdk/mistral": + case "@ai-sdk/groq": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/togetherai": + case "venice-ai-sdk-provider": + case "ai-gateway-provider": + return { reasoningEffort: effort } + case "@ai-sdk/cohere": + case "@ai-sdk/perplexity": + case "@ai-sdk/vercel": + case "@ai-sdk/alibaba": + case "gitlab-ai-provider": + return + } +} + +function anthropicEffort(model: Provider.Model, effort: string) { + if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort } + if (!anthropicAdaptiveEfforts(model.api.id)) return + return { + thinking: { + type: "adaptive", + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + effort, + } +} + +function reasoningBudget(model: Provider.Model, budget: number) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { max_tokens: budget } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return { thinking: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + case "@ai-sdk/amazon-bedrock": + return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + return + case "@ai-sdk/cohere": + return { thinking: { type: "enabled", tokenBudget: budget } } + case "@ai-sdk/alibaba": + return { enableThinking: true, thinkingBudget: budget } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } } + if (model.id.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + return + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/azure": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/github-copilot": + case "@ai-sdk/groq": + case "@ai-sdk/mistral": + case "@ai-sdk/openai": + case "@ai-sdk/openai-compatible": + case "@ai-sdk/perplexity": + case "@ai-sdk/togetherai": + case "@ai-sdk/vercel": + case "@ai-sdk/xai": + case "ai-gateway-provider": + case "gitlab-ai-provider": + case "venice-ai-sdk-provider": + return + } +} + export * as ProviderTransform from "./transform" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index b5f8b8ecf8..b78bdae8bc 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -562,6 +562,45 @@ it.instance( }, ) +it.instance( + "model config preserves explicitly empty models.dev variants", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"] + expect(model.name).toBe("Custom GPT Chat") + expect(model.variants).toEqual({}) + }), + { + config: { + provider: { + openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } }, + }, + }, + }, +) + +it.instance( + "model config regenerates variants when overriding the provider package", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants?.low).toEqual({ reasoningEffort: "low" }) + expect(model.variants?.max).toBeUndefined() + }), + { + config: { + provider: { + anthropic: { + npm: "@ai-sdk/openai-compatible", + models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } }, + }, + }, + }, + }, +) + it.instance( "disabled_providers prevents loading even with env var", Effect.gen(function* () { @@ -1426,52 +1465,60 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) -test("models.dev reasoning options normalize to known shapes", () => { - const provider = Provider.fromModelsDevProvider({ - id: "openai", - name: "OpenAI", +test("models.dev reasoning options replace generated variants and unsupported options fall back", () => { + const provider = { + id: "reasoning", + name: "Reasoning", env: [], npm: "@ai-sdk/openai", models: { - reasoner: { - id: "reasoner", - name: "Reasoner", - reasoning: true, - reasoning_options: [ - { type: "future_control", value: true }, - { type: "effort", values: ["high", null, 42] }, - { type: "toggle" }, - { type: "budget_tokens", min: 1024, max: "invalid" }, - ], - limit: { context: 128_000, output: 16_000 }, - }, - }, - } as unknown as ModelsDev.Provider) - - expect(provider.models.reasoner.reasoning_options).toEqual([ - { type: "effort", values: ["high", null] }, - { type: "toggle" }, - { type: "budget_tokens", min: 1024 }, - ]) -}) - -test("models.dev models without reasoning options normalize to an empty list", () => { - const provider = Provider.fromModelsDevProvider({ - id: "openai", - name: "OpenAI", - env: [], - npm: "@ai-sdk/openai", - models: { - reasoner: { + explicit: { id: "gpt-5.4", - name: "Reasoner", + name: "Explicit", reasoning: true, - limit: { context: 128_000, output: 16_000 }, + reasoning_options: [{ type: "effort", values: ["low"] }], + limit: { context: 128_000, output: 64_000 }, + }, + empty: { + id: "gpt-5.4", + name: "Empty", + reasoning: true, + reasoning_options: [], + limit: { context: 128_000, output: 64_000 }, + }, + fallback: { + id: "gpt-5.4", + name: "Fallback", + reasoning: true, + reasoning_options: [{ type: "toggle" }], + limit: { context: 128_000, output: 64_000 }, + }, + override: { + id: "gemini-3-pro", + name: "Override", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["high"] }], + provider: { npm: "@ai-sdk/google" }, + limit: { context: 128_000, output: 64_000 }, + experimental: { modes: { fast: {} } }, }, }, - } as unknown as ModelsDev.Provider) + } as unknown as ModelsDev.Provider - expect(provider.models.reasoner.reasoning_options).toEqual([]) + const models = Provider.fromModelsDevProvider(provider).models + expect(models.explicit.variants).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + expect(models.empty.variants).toEqual({}) + expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(models.override.variants).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants) }) test("public provider info omits invalid models", () => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3ef2ca3bd0..a144997405 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { @@ -2988,6 +2989,242 @@ describe("ProviderTransform.temperature - Cohere North", () => { }) }) +describe("ProviderTransform.reasoningVariants", () => { + const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model + const target = (npm: string, id = "test-model") => + ({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any + + test("respects explicitly empty reasoning options", () => { + expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"], + [ + "@ai-sdk/google-vertex/anthropic", + { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + "claude-opus-4-7", + ], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + [ + "@ai-sdk/azure", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/openai", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/amazon-bedrock/mantle", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/github-copilot", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }], + ["@ai-sdk/xai", { reasoningEffort: "high" }], + ["@ai-sdk/mistral", { reasoningEffort: "high" }], + ["@ai-sdk/groq", { reasoningEffort: "high" }], + ["@ai-sdk/cerebras", { reasoningEffort: "high" }], + ["@ai-sdk/deepinfra", { reasoningEffort: "high" }], + ["@ai-sdk/togetherai", { reasoningEffort: "high" }], + ["venice-ai-sdk-provider", { reasoningEffort: "high" }], + ["ai-gateway-provider", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }], + ])("converts effort for %s", (npm, expected, ...args) => { + const id = args[0] as string | undefined + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual( + { high: expected }, + ) + }) + + test("uses bare effort for Claude Opus 4.5", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-opus-4-5"), + ), + ).toEqual({ high: { effort: "high" } }) + }) + + test("leaves legacy Anthropic effort options to budget fallback", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-sonnet-4"), + ), + ).toBeUndefined() + }) + + test("uses adaptive reasoning config for Anthropic models on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }, + }) + }) + + test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"), + ), + ).toBeUndefined() + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }], + ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], + ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], + ])("converts token budgets for %s", (npm, high) => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 16_000 }]), target(npm)), + ).toEqual({ high }) + }) + + test("maps null effort to none", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")), + ).toEqual({ + none: { + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test.each([ + ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }], + [ + "@ai-sdk/cohere", + { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + }, + ], + ])("converts toggle options for %s", (npm, expected) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected) + }) + + test("combines Cohere toggle and budget options", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]), + target("@ai-sdk/cohere"), + ), + ).toEqual({ + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, + }) + }) + + test("generates bounded high and max token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), + target("@ai-sdk/anthropic"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 63_999 } }, + }) + }) + + test("caps token budgets below the model output limit", () => { + const anthropic = target("@ai-sdk/anthropic") + anthropic.limit.output = 5_000 + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + }) + }) + + test("prefers effort options over token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([ + { type: "budget_tokens", min: 1_024, max: 64_000 }, + { type: "effort", values: ["low"] }, + ]), + target("@ai-sdk/openai"), + ), + ).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test("leaves unsupported options for heuristic fallback", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")), + ).toBeUndefined() + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined() + }) + + test("uses model-family options for gateway and GitHub Copilot", () => { + const effort = model([{ type: "effort", values: ["high"] }]) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual( + { + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect( + ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")), + ).toBeUndefined() + }) + + test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])( + "does not invent effort controls for %s", + (npm) => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)), + ).toBeUndefined() + }, + ) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38c2e1a2b4..5e067f3afb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2100,20 +2100,6 @@ export type Model = { [key: string]: string } release_date: string - reasoning_options?: Array< - | { - type: "effort" - values: Array - } - | { - type: "toggle" - } - | { - type: "budget_tokens" - min?: number - max?: number - } - > variants?: { [key: string]: { [key: string]: unknown diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8fc81713bc..d97885b553 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21777,58 +21777,6 @@ "release_date": { "type": "string" }, - "reasoning_options": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["effort"] - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "values"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["toggle"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["budget_tokens"] - }, - "min": { - "type": "number" - }, - "max": { - "type": "number" - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, "variants": { "type": "object", "additionalProperties": { From dd02cea9e7cecd6d5eec6ffcab6741cb51f7f784 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:02:38 +0200 Subject: [PATCH 07/38] feat(desktop): fix clipped labels and branch tooltip (#35724) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/components/prompt-input.tsx | 2 +- .../src/components/prompt-workspace-selector.tsx | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e8722..3d6b83a9e9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -2341,7 +2341,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo /> )} - {props.state.modelName} + {props.state.modelName} diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index 27d8c97ca2..35ac4f9c6e 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -1,5 +1,6 @@ import { For, Show } from "solid-js" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { getFilename } from "@opencode-ai/core/util/path" @@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: { {(branch) => ( <> -
- - {branch()} -
+ +
+ + {branch()} +
+
)} From 140e796b219dd5275f8742010ccc1257a7f5c3c0 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:42:07 +0200 Subject: [PATCH 08/38] feat(ui): add accent badge variant --- packages/ui/src/v2/components/badge-v2.css | 6 ++++++ packages/ui/src/v2/components/badge-v2.stories.tsx | 7 ++++++- packages/ui/src/v2/components/badge-v2.tsx | 7 +++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css index 748c08a711..44dfc7489d 100644 --- a/packages/ui/src/v2/components/badge-v2.css +++ b/packages/ui/src/v2/components/badge-v2.css @@ -25,3 +25,9 @@ [data-component="tag"][data-high-contrast] { border-color: var(--v2-border-border-strong); } + +[data-component="tag"][data-variant="accent"] { + border: 0; + background: var(--v2-background-bg-accent); + color: var(--v2-text-text-inverse); +} diff --git a/packages/ui/src/v2/components/badge-v2.stories.tsx b/packages/ui/src/v2/components/badge-v2.stories.tsx index 084838c0e1..19af508bc2 100644 --- a/packages/ui/src/v2/components/badge-v2.stories.tsx +++ b/packages/ui/src/v2/components/badge-v2.stories.tsx @@ -8,10 +8,11 @@ Use alongside headings or lists for quick metadata. ### API - Accepts standard span props. +- Optional: \`variant\` is \`neutral\` (default) or \`accent\`. - Optional: \`data-high-contrast\` attribute for stronger border contrast. ### Variants and states -- Single size style. +- Neutral and accent variants. - Optional high-contrast border style. ### Behavior @@ -52,3 +53,7 @@ export const HighContrast = { ), } + +export const Accent = { + render: () => New, +} diff --git a/packages/ui/src/v2/components/badge-v2.tsx b/packages/ui/src/v2/components/badge-v2.tsx index 3bb06259e1..8583d46da9 100644 --- a/packages/ui/src/v2/components/badge-v2.tsx +++ b/packages/ui/src/v2/components/badge-v2.tsx @@ -1,14 +1,17 @@ import { type ComponentProps, splitProps } from "solid-js" import "./badge-v2.css" -export interface TagProps extends ComponentProps<"span"> {} +export interface TagProps extends ComponentProps<"span"> { + variant?: "neutral" | "accent" +} export function Tag(props: TagProps) { - const [split, rest] = splitProps(props, ["class", "classList", "children"]) + const [split, rest] = splitProps(props, ["class", "classList", "children", "variant"]) return ( Date: Wed, 8 Jul 2026 19:42:18 +0200 Subject: [PATCH 09/38] feat(app): add interface transition setting --- .../app/src/components/settings-general.tsx | 56 ++++++++++++------ .../src/components/settings-v2/general.tsx | 57 +++++++++++++------ packages/app/src/context/settings.tsx | 2 + packages/app/src/i18n/ar.ts | 7 ++- packages/app/src/i18n/br.ts | 7 ++- packages/app/src/i18n/bs.ts | 7 ++- packages/app/src/i18n/da.ts | 7 ++- packages/app/src/i18n/de.ts | 7 ++- packages/app/src/i18n/en.ts | 6 +- packages/app/src/i18n/es.ts | 7 ++- packages/app/src/i18n/fr.ts | 7 ++- packages/app/src/i18n/ja.ts | 7 ++- packages/app/src/i18n/ko.ts | 6 +- packages/app/src/i18n/no.ts | 7 ++- packages/app/src/i18n/pl.ts | 7 ++- packages/app/src/i18n/ru.ts | 7 ++- packages/app/src/i18n/th.ts | 7 ++- packages/app/src/i18n/tr.ts | 7 ++- packages/app/src/i18n/uk.ts | 7 ++- packages/app/src/i18n/zh.ts | 5 +- packages/app/src/i18n/zht.ts | 5 +- 21 files changed, 150 insertions(+), 85 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c424a62a89..ad68b3e124 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useParams } from "@solidjs/router" @@ -23,6 +24,7 @@ import { sansInput, terminalDefault, terminalFontFamily, + oldInterfaceSunset, terminalInput, useSettings, } from "@/context/settings" @@ -248,6 +250,39 @@ export const SettingsGeneral: Component = () => { triggerVariant: "settings" as const, }) + const InterfaceSection = () => ( +
+ + + {language.t("settings.general.row.newInterface.title")} + {language.t("settings.general.row.newInterface.badge")} + + } + description={language.t("settings.general.row.newInterface.description", { + date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( + oldInterfaceSunset, + ), + })} + > +
+ { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + void dialog.show(() => ) + }) + }} + /> +
+
+
+
+ ) + const GeneralSection = () => (
@@ -335,23 +370,6 @@ export const SettingsGeneral: Component = () => {
- -
- { - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - dialog.show(() => ) - }) - }} - /> -
-
) @@ -718,6 +736,10 @@ export const SettingsGeneral: Component = () => {
+ + + + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 69dfb77fb0..4e210c4edb 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,5 +1,6 @@ import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" @@ -21,6 +22,7 @@ import { sansInput, terminalDefault, terminalFontFamily, + oldInterfaceSunset, terminalInput, useSettings, } from "@/context/settings" @@ -226,6 +228,39 @@ export const SettingsGeneralV2: Component<{ }, }) + const InterfaceSection = () => ( +
+ + + {language.t("settings.general.row.newInterface.title")} + {language.t("settings.general.row.newInterface.badge")} + + } + description={language.t("settings.general.row.newInterface.description", { + date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( + oldInterfaceSunset, + ), + })} + > +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + void dialog.show(() => ) + }) + }} + /> +
+
+
+
+ ) + const GeneralSection = () => (
@@ -312,24 +347,6 @@ export const SettingsGeneralV2: Component<{
- -
- { - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - dialog.show(() => ) - }) - }} - /> -
-
-
+ + + + diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 8d4f61079c..da655f53a9 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -53,6 +53,8 @@ export const monoDefault = "System Mono" export const sansDefault = "System Sans" export const terminalDefault = "JetBrainsMono Nerd Font Mono" export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" +// Date the old interface is retired; the settings toggle references this countdown. +export const oldInterfaceSunset = new Date(2026, 7, 6) const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 2b639eded7..0c0c568932 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -735,9 +735,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.description": "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", - "settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة", - "settings.general.row.newLayoutDesigns.description": - "تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها", + "settings.general.row.newInterface.title": "استخدام الواجهة الجديدة", + "settings.general.row.newInterface.badge": "جديد", + "settings.general.row.newInterface.description": + "تخطيط وعلامات تبويب ومحرر رسائل وصفحة رئيسية معاد تصميمها. ستختفي الواجهة السابقة في {{date}}.", "settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص", "settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 688b05fee1..fdf487a8c4 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -746,9 +746,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.description": "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", - "settings.general.row.newLayoutDesigns.title": "Novo layout e design", - "settings.general.row.newLayoutDesigns.description": - "Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados", + "settings.general.row.newInterface.title": "Usar a nova interface", + "settings.general.row.newInterface.badge": "Novo", + "settings.general.row.newInterface.description": + "Layout, abas, área de composição e página inicial reformulados. A interface anterior será removida em {{date}}.", "settings.general.row.pinchZoom.title": "Zoom com gesto de pinça", "settings.general.row.pinchZoom.description": "Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e0d4e2aa03..ae804ff8d1 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -811,9 +811,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.description": "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", - "settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn", - "settings.general.row.newLayoutDesigns.description": - "Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije", + "settings.general.row.newInterface.title": "Koristi novi interfejs", + "settings.general.row.newInterface.badge": "Novo", + "settings.general.row.newInterface.description": + "Redizajnirani raspored, kartice, uređivač poruke i nova početna stranica. Stari interfejs nestaje {{date}}.", "settings.general.row.pinchZoom.title": "Zumiranje štipanjem", "settings.general.row.pinchZoom.description": "Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 3b02b6a026..47d710414f 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -804,9 +804,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", - "settings.general.row.newLayoutDesigns.title": "Nyt layout og design", - "settings.general.row.newLayoutDesigns.description": - "Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen", + "settings.general.row.newInterface.title": "Brug den nye brugerflade", + "settings.general.row.newInterface.badge": "Ny", + "settings.general.row.newInterface.description": + "Et nydesignet layout, faner, promptfelt og ny startside. Den gamle brugerflade forsvinder den {{date}}.", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome", "settings.general.row.wayland.title": "Brug native Wayland", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index cf530f6488..c969e3523c 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -759,9 +759,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.description": "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", - "settings.general.row.newLayoutDesigns.title": "Neues Layout und Design", - "settings.general.row.newLayoutDesigns.description": - "Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren", + "settings.general.row.newInterface.title": "Neue Oberfläche verwenden", + "settings.general.row.newInterface.badge": "Neu", + "settings.general.row.newInterface.description": + "Neu gestaltetes Layout, Tabs, Eingabebereich und neue Startseite. Die alte Oberfläche wird am {{date}} entfernt.", "settings.general.row.pinchZoom.title": "Zoom per Fingergeste", "settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben", "settings.general.row.wayland.title": "Natives Wayland verwenden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 9d122447a8..54df4d232c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -899,8 +899,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.description": "Show edit, write, and patch tool parts expanded by default in the timeline", - "settings.general.row.newLayoutDesigns.title": "New layout and designs", - "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", + "settings.general.row.newInterface.title": "Use the new interface", + "settings.general.row.newInterface.badge": "New", + "settings.general.row.newInterface.description": + "A redesigned layout, tabs, composer and new home. Old interface goes away on {{date}}.", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 0610f2de00..db3b0e356f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -816,9 +816,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.description": "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", - "settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos", - "settings.general.row.newLayoutDesigns.description": - "Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones", + "settings.general.row.newInterface.title": "Usar la nueva interfaz", + "settings.general.row.newInterface.badge": "Nuevo", + "settings.general.row.newInterface.description": + "Un diseño renovado con pestañas, editor y nueva página de inicio. La interfaz anterior desaparecerá el {{date}}.", "settings.general.row.pinchZoom.title": "Pellizcar para ampliar", "settings.general.row.pinchZoom.description": "Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 3ed47d5dba..f51a7d2562 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -756,9 +756,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.description": "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", - "settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs", - "settings.general.row.newLayoutDesigns.description": - "Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session", + "settings.general.row.newInterface.title": "Utiliser la nouvelle interface", + "settings.general.row.newInterface.badge": "Nouveau", + "settings.general.row.newInterface.description": + "Une mise en page repensée, des onglets, une zone de saisie et un nouvel accueil. L'ancienne interface disparaîtra le {{date}}.", "settings.general.row.pinchZoom.title": "Pincer pour zoomer", "settings.general.row.pinchZoom.description": "Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 52ec83b3c4..0c89569d43 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -741,9 +741,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.description": "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", - "settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン", - "settings.general.row.newLayoutDesigns.description": - "刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします", + "settings.general.row.newInterface.title": "新しいインターフェースを使用", + "settings.general.row.newInterface.badge": "新機能", + "settings.general.row.newInterface.description": + "刷新されたレイアウト、タブ、コンポーザー、ホーム画面です。以前のインターフェースは{{date}}に終了します。", "settings.general.row.pinchZoom.title": "ピンチでズーム", "settings.general.row.pinchZoom.description": "トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 738123fe03..de4d1337e3 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -999,8 +999,10 @@ export const dict = { "settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치", "settings.general.row.showCustomAgents.title": "사용자 지정 에이전트", "settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시", - "settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인", - "settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화", + "settings.general.row.newInterface.title": "새 인터페이스 사용", + "settings.general.row.newInterface.badge": "신규", + "settings.general.row.newInterface.description": + "새롭게 디자인된 레이아웃, 탭, 입력창과 새 홈 화면입니다. 이전 인터페이스는 {{date}}에 종료됩니다.", "settings.general.row.pinchZoom.title": "핀치 줌", "settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용", "settings.updates.action.downloading": "다운로드 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0a37f8bf32..c8e17c8f96 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -1095,9 +1095,10 @@ export const dict = { "Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen", "settings.general.row.showCustomAgents.title": "Egendefinerte agenter", "settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet", - "settings.general.row.newLayoutDesigns.title": "Ny layout og utforming", - "settings.general.row.newLayoutDesigns.description": - "Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet", + "settings.general.row.newInterface.title": "Bruk det nye grensesnittet", + "settings.general.row.newInterface.badge": "Ny", + "settings.general.row.newInterface.description": + "Et redesignet oppsett med faner, skrivefelt og ny startside. Det gamle grensesnittet forsvinner {{date}}.", "settings.general.row.pinchZoom.title": "Knip for å zoome", "settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome", "settings.updates.action.downloading": "Laster ned...", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f56e023aed..52b303a589 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -746,9 +746,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.description": "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", - "settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd", - "settings.general.row.newLayoutDesigns.description": - "Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji", + "settings.general.row.newInterface.title": "Użyj nowego interfejsu", + "settings.general.row.newInterface.badge": "Nowość", + "settings.general.row.newInterface.description": + "Przeprojektowany układ, karty, edytor i nowa strona główna. Poprzedni interfejs zniknie {{date}}.", "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0d68552e6d..ccc092b9d6 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -812,9 +812,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.description": "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", - "settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление", - "settings.general.row.newLayoutDesigns.description": - "Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии", + "settings.general.row.newInterface.title": "Использовать новый интерфейс", + "settings.general.row.newInterface.badge": "Новое", + "settings.general.row.newInterface.description": + "Обновлённая компоновка, вкладки, редактор запросов и новая главная страница. Прежний интерфейс исчезнет {{date}}.", "settings.general.row.pinchZoom.title": "Масштабирование щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 91c009be0c..e2594fe29b 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -801,9 +801,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.description": "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", - "settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่", - "settings.general.row.newLayoutDesigns.description": - "เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่", + "settings.general.row.newInterface.title": "ใช้อินเทอร์เฟซใหม่", + "settings.general.row.newInterface.badge": "ใหม่", + "settings.general.row.newInterface.description": + "เลย์เอาต์ แท็บ ช่องเขียนข้อความ และหน้าแรกที่ออกแบบใหม่ อินเทอร์เฟซเดิมจะเลิกใช้งานในวันที่ {{date}}", "settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม", "settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index ff17d0001c..97757b4aa5 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -816,9 +816,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", "settings.general.row.editToolPartsExpanded.description": "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar", - "settings.general.row.newLayoutDesigns.description": - "Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir", + "settings.general.row.newInterface.title": "Yeni arayüzü kullan", + "settings.general.row.newInterface.badge": "Yeni", + "settings.general.row.newInterface.description": + "Yeniden tasarlanan düzen, sekmeler, yazma alanı ve yeni ana sayfa. Eski arayüz {{date}} tarihinde kaldırılacak.", "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", "settings.general.row.pinchZoom.description": "İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c6a0ae9629..3652bd587d 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -902,9 +902,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.description": "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", - "settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн", - "settings.general.row.newLayoutDesigns.description": - "Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії", + "settings.general.row.newInterface.title": "Використовувати новий інтерфейс", + "settings.general.row.newInterface.badge": "Нове", + "settings.general.row.newInterface.description": + "Оновлений макет, вкладки, редактор запитів і нова головна сторінка. Попередній інтерфейс зникне {{date}}.", "settings.general.row.pinchZoom.title": "Масштабування щипком", "settings.general.row.pinchZoom.description": "Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a44bfc107f..d2d0540f20 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -796,8 +796,9 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", - "settings.general.row.newLayoutDesigns.title": "新版布局和设计", - "settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面", + "settings.general.row.newInterface.title": "使用新界面", + "settings.general.row.newInterface.badge": "新", + "settings.general.row.newInterface.description": "重新设计的布局、标签页、输入框和主页。旧界面将于{{date}}停用。", "settings.general.row.pinchZoom.title": "双指缩放", "settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放", "settings.general.row.wayland.title": "使用原生 Wayland", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index e9a1fea29e..10076d0bb9 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -792,8 +792,9 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", - "settings.general.row.newLayoutDesigns.title": "新版面與設計", - "settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面", + "settings.general.row.newInterface.title": "使用新介面", + "settings.general.row.newInterface.badge": "新", + "settings.general.row.newInterface.description": "重新設計的版面、分頁、輸入區和首頁。舊介面將於{{date}}停用。", "settings.general.row.pinchZoom.title": "雙指縮放", "settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放", "settings.general.row.wayland.title": "使用原生 Wayland", From 97f502e79a209ed917e2918e84c43f29bf5f394b Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:53:55 +0530 Subject: [PATCH 10/38] feat(app): middle click to open new tab (#36215) --- .../app/src/pages/home-session-open.test.ts | 29 +++++++++++++++---- packages/app/src/pages/home-session-open.ts | 3 ++ packages/app/src/pages/home.tsx | 21 ++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts index a71d58ff07..f6769d84d6 100644 --- a/packages/app/src/pages/home-session-open.test.ts +++ b/packages/app/src/pages/home-session-open.test.ts @@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test" import { shouldOpenSessionInBackground } from "./home-session-open" describe("shouldOpenSessionInBackground", () => { + test("opens middle clicks in the background", () => { + expect(shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( + false, + ) + }) + test("requires only the platform primary modifier", () => { - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) + expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe( + false, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe( + false, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe( + false, + ) }) }) diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts index 8e32efb559..9f117935b7 100644 --- a/packages/app/src/pages/home-session-open.ts +++ b/packages/app/src/pages/home-session-open.ts @@ -1,10 +1,13 @@ export function shouldOpenSessionInBackground(input: { + button: number mac: boolean meta: boolean ctrl: boolean shift: boolean alt: boolean }) { + if (input.button === 1) return true + if (input.button !== 0) return false if (input.shift || input.alt) return false if (input.mac) return input.meta && !input.ctrl return input.ctrl && !input.meta diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672df..a60ec2977c 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -240,10 +240,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } } -// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the -// background without navigating, matching browser conventions. +// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session +// tab in the background without navigating, matching browser conventions. function isBackgroundOpen(event: MouseEvent) { return shouldOpenSessionInBackground({ + button: event.button, mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), meta: event.metaKey, ctrl: event.ctrlKey, @@ -1386,7 +1387,15 @@ function HomeSessionSearchResultRow(props: { group: !!showProjectName(), }} onMouseEnter={() => props.onHighlight()} + onMouseDown={(event) => { + if (event.button === 1) event.preventDefault() + }} onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })} + onAuxClick={(event) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.onSelect(props.record.session, { background: true }) + }} > { + if (event.button === 1) event.preventDefault() + }} onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })} + onAuxClick={(event) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.openSession(props.record.session, { background: true }) + }} > Date: Mon, 13 Jul 2026 09:25:14 +0000 Subject: [PATCH 11/38] chore: generate --- .../app/src/pages/home-session-open.test.ts | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts index f6769d84d6..74b9426654 100644 --- a/packages/app/src/pages/home-session-open.test.ts +++ b/packages/app/src/pages/home-session-open.test.ts @@ -3,29 +3,29 @@ import { shouldOpenSessionInBackground } from "./home-session-open" describe("shouldOpenSessionInBackground", () => { test("opens middle clicks in the background", () => { - expect(shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( - true, - ) - expect(shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( - false, - ) + expect( + shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }), + ).toBe(false) }) test("requires only the platform primary modifier", () => { - expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe( - true, - ) - expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe( - true, - ) - expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe( - false, - ) - expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe( - false, - ) - expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe( - false, - ) + expect( + shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }), + ).toBe(false) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }), + ).toBe(false) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }), + ).toBe(false) }) }) From 17cd4a8c32a5193891a12a24734f0a9e4d4ddedb Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:56:53 +0530 Subject: [PATCH 12/38] feat(app): align edit project modal with v2 style (#36213) Co-authored-by: Brendan Allan --- .../src/components/dialog-edit-project-v2.tsx | 156 ++++++++++++++++ .../src/components/dialog-edit-project.tsx | 173 ++++-------------- packages/app/src/components/edit-project.ts | 119 ++++++++++++ packages/app/src/context/layout.tsx | 16 +- packages/app/src/pages/home.tsx | 4 +- packages/ui/src/v2/components/icon.tsx | 4 + .../src/v2/components/project-avatar-v2.tsx | 5 +- 7 files changed, 333 insertions(+), 144 deletions(-) create mode 100644 packages/app/src/components/dialog-edit-project-v2.tsx create mode 100644 packages/app/src/components/edit-project.ts diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx new file mode 100644 index 0000000000..dc9714aa06 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -0,0 +1,156 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Field } from "@opencode-ai/ui/v2/field-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { createEditProjectModel } from "./edit-project" + +export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+ + {language.t("dialog.project.edit.title")} + + + + + {language.t("dialog.project.edit.name")} + model.setStore("name", event.currentTarget.value)} + /> + + +
+
+ {language.t("dialog.project.edit.icon")} +
+
+ + { + model.setIconInput(element) + }} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+
+ {language.t("dialog.project.edit.color")} +
+
+ + {(color) => ( + + )} + +
+
+
+ + + {language.t("dialog.project.edit.worktree.startup")} + {language.t("dialog.project.edit.worktree.startup.description")} + model.setStore("startup", event.currentTarget.value)} + /> + +
+ + + {language.t("common.cancel")} + + + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} + + + +
+ ) +} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index b861492af5..86a9630359 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,123 +1,32 @@ import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" -import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, For, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { For, Show } from "solid-js" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" import { ServerConnection } from "@/context/server" -import { useGlobal } from "@/context/global" +import { createEditProjectModel } from "./edit-project" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { - const dialog = useDialog() - const global = useGlobal() const language = useLanguage() - const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) - const serverSDK = () => serverCtx().sdk - const serverSync = () => serverCtx().sync - - const folderName = createMemo(() => getFilename(props.project.worktree)) - const defaultName = createMemo(() => props.project.name || folderName()) - - const [store, setStore] = createStore({ - name: defaultName(), - color: props.project.icon?.color, - iconOverride: props.project.icon?.override, - startup: props.project.commands?.start ?? "", - dragOver: false, - iconHover: false, - }) - - let iconInput: HTMLInputElement | undefined - - function handleFileSelect(file: File) { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (e) => { - setStore("iconOverride", e.target?.result as string) - setStore("iconHover", false) - } - reader.readAsDataURL(file) - } - - function handleDrop(e: DragEvent) { - e.preventDefault() - setStore("dragOver", false) - const file = e.dataTransfer?.files[0] - if (file) handleFileSelect(file) - } - - function handleDragOver(e: DragEvent) { - e.preventDefault() - setStore("dragOver", true) - } - - function handleDragLeave() { - setStore("dragOver", false) - } - - function handleInputChange(e: Event) { - const input = e.target as HTMLInputElement - const file = input.files?.[0] - if (file) handleFileSelect(file) - } - - function clearIcon() { - setStore("iconOverride", "") - } - - const saveMutation = useMutation(() => ({ - mutationFn: async () => { - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await serverSDK().client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return - } - - serverSync().project.meta(props.project.worktree, { - name, - icon: { color: store.color || undefined, override: store.iconOverride || undefined }, - commands: { start: start || undefined }, - }) - dialog.close() - }, - })) - - function handleSubmit(e: SubmitEvent) { - e.preventDefault() - if (saveMutation.isPending) return - saveMutation.mutate() - } + const model = createEditProjectModel(props) return ( -
+
setStore("name", v)} + placeholder={model.folderName()} + value={model.store.name} + onChange={(v) => model.setStore("name", v)} />
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
setStore("iconHover", true)} - onMouseLeave={() => setStore("iconHover", false)} + onMouseEnter={() => model.setStore("iconHover", true)} + onMouseLeave={() => model.setStore("iconHover", false)} >
{ - if (store.iconOverride && store.iconHover) { - clearIcon() - } else { - iconInput?.click() - } + "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver, + "border-border-base hover:border-border-strong": !model.store.dragOver, + "overflow-hidden": !!model.store.iconOverride, }} + onDrop={model.drop} + onDragOver={model.dragOver} + onDragLeave={model.dragLeave} + onClick={model.iconClick} >
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - iconInput = el + model.setIconInput(el) }} type="file" accept="image/*" class="hidden" - onChange={handleInputChange} + onChange={model.inputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- -
diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts new file mode 100644 index 0000000000..3ec999da06 --- /dev/null +++ b/packages/app/src/components/edit-project.ts @@ -0,0 +1,119 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobal } from "@/context/global" +import { type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" + +export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, + startup: props.project.commands?.start ?? "", + dragOver: false, + iconHover: false, + }) + let iconInput: HTMLInputElement | undefined + + function selectFile(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (event) => { + const result = event.target?.result + if (typeof result !== "string") return + setStore("iconOverride", result) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function drop(event: DragEvent) { + event.preventDefault() + setStore("dragOver", false) + const file = event.dataTransfer?.files[0] + if (file) selectFile(file) + } + + function dragOver(event: DragEvent) { + event.preventDefault() + setStore("dragOver", true) + } + + function dragLeave() { + setStore("dragOver", false) + } + + function inputChange(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0] + if (file) selectFile(file) + } + + function iconClick() { + if (store.iconOverride && store.iconHover) { + setStore("iconOverride", "") + return + } + iconInput?.click() + } + + const save = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + await serverCtx().sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverCtx().sync.project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function submit(event: SubmitEvent) { + event.preventDefault() + if (save.isPending) return + save.mutate() + } + + return { + store, + setStore, + folderName, + defaultName, + save, + submit, + drop, + dragOver, + dragLeave, + inputChange, + iconClick, + close() { + dialog.close() + }, + setIconInput(input: HTMLInputElement) { + iconInput = input + }, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index dcac3e7781..7ac248dc1d 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) { } export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { - if (key === "orange") return "orange" - if (key === "pink") return "pink" - if (key === "cyan") return "cyan" - if (key === "purple") return "purple" if (key === "mint") return "cyan" if (key === "lime") return "green" + if ( + key === "orange" || + key === "yellow" || + key === "cyan" || + key === "green" || + key === "red" || + key === "pink" || + key === "blue" || + key === "purple" || + key === "gray" + ) + return key return "gray" } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index a60ec2977c..d697e53ae7 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -466,8 +466,8 @@ export function NewHome() { } function editProject(conn: ServerConnection.Any, project: LocalProject) { - void import("@/components/dialog-edit-project").then((x) => { - dialog.show(() => ) + void import("@/components/dialog-edit-project-v2").then((x) => { + void dialog.show(() => ) }) } diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b0b70bf37f..da402fd4d9 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -129,6 +129,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-share": { + viewBox: "0 0 16 16", + body: ``, + }, reset: { viewBox: "0 0 20 20", body: ``, diff --git a/packages/ui/src/v2/components/project-avatar-v2.tsx b/packages/ui/src/v2/components/project-avatar-v2.tsx index 22da7f6b72..cbc571ab53 100644 --- a/packages/ui/src/v2/components/project-avatar-v2.tsx +++ b/packages/ui/src/v2/components/project-avatar-v2.tsx @@ -38,7 +38,6 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> { export function ProjectAvatar(props: ProjectAvatarProps) { const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"]) - const src = split.src return (
- + {(value) => }
From d595c7e265e53e9a2f8fecddf9e37e6867430470 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:02:25 +0530 Subject: [PATCH 13/38] feat(app): review panel updates (#36240) Co-authored-by: Brendan Allan --- .../file-browser-sidebar-tab-switch.spec.ts | 150 +++++ .../e2e/regression/review-open-file.spec.ts | 10 +- .../e2e/regression/review-tab-switch.spec.ts | 4 +- .../review-terminal-stacked.spec.ts | 2 +- .../src/components/file-tree-v2-model.test.ts | 44 +- .../app/src/components/file-tree-v2-model.ts | 30 + packages/app/src/components/file-tree-v2.tsx | 87 ++- packages/app/src/components/session/index.ts | 1 + .../src/components/session/open-in-app-v2.tsx | 101 ++++ .../src/components/session/open-in-app.tsx | 232 +++++++ .../session/session-sortable-tab-v2.tsx | 66 ++ packages/app/src/i18n/en.ts | 2 +- packages/app/src/pages/session.tsx | 8 + packages/app/src/pages/session/file-tabs.tsx | 331 +++++++++- .../src/pages/session/session-side-panel.tsx | 570 +++++++++++++----- .../src/pages/session/v2/review-panel-v2.tsx | 8 +- .../session/v2/session-file-browser-tab.tsx | 163 +++-- .../line-comment-annotations-v2.tsx | 8 + .../src/v2/components/session-review-v2.css | 7 +- .../src/v2/components/session-review-v2.tsx | 5 +- packages/ui/src/components/tabs.css | 96 +++ .../ui/src/v2/components/file-tree-v2.css | 19 +- .../ui/src/v2/components/line-comment-v2.css | 53 ++ .../ui/src/v2/components/line-comment-v2.tsx | 154 ++++- .../ui/src/v2/components/split-button-v2.css | 99 +++ .../ui/src/v2/components/split-button-v2.tsx | 48 ++ 26 files changed, 1998 insertions(+), 300 deletions(-) create mode 100644 packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts create mode 100644 packages/app/src/components/session/open-in-app-v2.tsx create mode 100644 packages/app/src/components/session/open-in-app.tsx create mode 100644 packages/app/src/components/session/session-sortable-tab-v2.tsx create mode 100644 packages/ui/src/v2/components/split-button-v2.css create mode 100644 packages/ui/src/v2/components/split-button-v2.tsx diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts new file mode 100644 index 0000000000..136232c211 --- /dev/null +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -0,0 +1,150 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/FileBrowserSidebar" +const projectID = "proj_file_browser_sidebar" +const sessionID = "ses_file_browser_sidebar" +const title = "File browser sidebar" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) +// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The file-browser sidebar must stay mounted across preview/pinned file-tab +// switches. Remounting resets scroll and filter state. +test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { + await setup(page) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() + + await panel.getByRole("button", { name: "file-00.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() + + const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const scrolled = await viewport.evaluate((element) => element.scrollTop) + expect(scrolled).toBeGreaterThan(0) + await writeProbe(page) + + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) + + await panel.getByRole("button", { name: "file-78.ts" }).dblclick() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("tab", { name: "file-78.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page + .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') + .evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "file-browser-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path) return [] + return files.map((name) => ({ + name, + path: name, + absolute: `${directory}/${name}`, + type: "file" as const, + ignored: false, + })) + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) +} diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 397c53f248..25ebd3a37a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => { const panel = page.locator("#review-panel") const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') + const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") - await expect(sidebar).toHaveCount(0) + await expect(sidebar).toBeHidden() await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(sidebar).toHaveCount(0) @@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => { await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await filter.press("Enter") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await panel.getByRole("tab", { name: /Review/ }).click() + await expect(sidebarToggle).toBeEnabled() + await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index c2ea406c5a..8634166e51 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - const reviewTab = page.getByRole("tab", { name: /Review/ }) - const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index e3ba607b3d..154bab48c4 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expectTree(page, 8, "git-0.ts") await selectMode(page, "Git changes", "Branch changes") - await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts index 288f7112e9..f2a63fe86d 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" -describe("file tree v2 model", () => { - test("builds sorted depth-first rows", () => { +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) expect(model.total).toBe(8) @@ -18,7 +19,7 @@ describe("file tree v2 model", () => { ]) }) - test("omits descendants of collapsed directories", () => { + test("skips children of collapsed directories", () => { const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ @@ -28,19 +29,46 @@ describe("file tree v2 model", () => { ]) }) - test("normalizes separators and duplicate paths", () => { + test("normalizes duplicate and messy paths", () => { const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const rows = flattenFileTreeV2(model, () => true) - expect(model.total).toBe(4) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") }) - test("supports paths deeper than the legacy recursion limit", () => { - const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" const model = buildFileTreeV2Model([file]) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) }) }) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 2127b6800f..27783a9bb1 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin return rows } + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index c712a80443..bb65d44ea8 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -36,7 +42,7 @@ function guideLineLeft(level: number) { export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" - return "" + return "M" } export const kindChange = (kind: Kind) => { @@ -68,7 +74,7 @@ const FileTreeNodeV2 = ( "class", "classList", ]) - const kind = () => local.kinds?.get(local.node.path) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) return ( {(_, index) => ( -
+
)} ) @@ -126,12 +129,18 @@ export default function FileTreeV2(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() + const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) - const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ @@ -155,16 +164,49 @@ export default function FileTreeV2(props: { return [...indexes, index].sort((a, b) => a - b) }, }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined createEffect(() => { const path = active() - if (!path) return + if (!path) { + scrolledActive = undefined + return + } const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path queueMicrotask(() => { - if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(index, { align: "auto" }) + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) }) }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), @@ -175,7 +217,7 @@ export default function FileTreeV2(props: {
@@ -209,13 +251,8 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - onClick={() => - props.onFileClick?.({ - ...row().node, - path: row().node.originalPath, - absolute: row().node.originalPath, - }) - } + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} > 0}> @@ -239,17 +276,13 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} - onClick={() => - file.tree.state(row().node.path)?.expanded === false - ? file.tree.expand(row().node.path, { list: false }) - : file.tree.collapse(row().node.path) - } + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} >
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 8e424f0f36..9e44cea17b 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx new file mode 100644 index 0000000000..61ffb2331e --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -0,0 +1,101 @@ +import { For, Show } from "solid-js" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app" + +export function OpenInAppV2(props: { directory: () => string }) { + const language = useLanguage() + const state = useOpenInApp(props) + + return ( + + event.stopPropagation()} + > + + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + if (state.opening()) return + state.openDir(state.current().id) + }} + disabled={state.opening()} + aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} + > + }> + + + + + state.setMenu("open", open)} + > + event.stopPropagation()} + > + + + + + + {language.t("session.header.openIn")} + { + state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + state.selectApp(option.id) + state.setMenu("open", false) + state.openDir(option.id) + }} + > + + {option.label} + + )} + + + + + { + state.setMenu("open", false) + state.copyPath() + }} + > + + {language.t("session.header.open.copyPath")} + + + + + + + ) +} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 0000000000..0d5cd90671 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,232 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" + +export const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +export type OpenApp = (typeof OPEN_APPS)[number] +export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" + +export const MAC_OPEN_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const WINDOWS_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const LINUX_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export function detectOpenAppOS(platform: ReturnType): OpenAppOS { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function openAppFileManager(os: OpenAppOS) { + if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } +} + +export function openAppsForOS(os: OpenAppOS) { + if (os === "macos") return MAC_OPEN_APPS + if (os === "windows") return WINDOWS_OPEN_APPS + return LINUX_OPEN_APPS +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function useOpenInApp(input: { directory: () => string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const os = createMemo(() => detectOpenAppOS(platform)) + const apps = createMemo(() => openAppsForOS(os())) + const fileManager = createMemo(() => openAppFileManager(os())) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const [prefs, setPrefs] = persisted( + Persist.global("open.app"), + createStore({ app: "finder" as OpenApp | "finder" }), + ) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp | "finder") => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp | "finder") => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = input.directory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = input.directory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + return { + canOpen, + opening, + current, + options, + menu, + setMenu, + openDir, + selectApp, + copyPath, + } +} diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx new file mode 100644 index 0000000000..158eba9f0b --- /dev/null +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -0,0 +1,66 @@ +import { createMemo, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { FileVisual } from "./session-sortable-tab" + +export function SortableTabV2(props: { + tab: string + index: () => number + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { + const file = useFile() + const language = useLanguage() + const command = useCommand() + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + }) + const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return + }) + return ( +
+
+ + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 9d122447a8..1cfdc51782 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -638,7 +638,7 @@ export const dict = { "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", "session.error.serverConnection": "Can't connect to this server", - "session.review.filesChanged": "{{count}} Files Changed", + "session.review.filesChanged": "Files Changed {{count}}", "session.review.change.one": "Change", "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 66afcfa9bf..8bf7a058ca 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -79,6 +79,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" +import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" @@ -2276,6 +2277,13 @@ export default function Page() { reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} + reviewSidebarToggle={(disabled) => ( + + )} fileBrowserState={reviewV2State} activeDiff={activeReviewFile()} focusReviewDiff={focusReviewDiff} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index dd51dd0585..ab56c05667 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" @@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations" +import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2" import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@/utils/toast" @@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" +import { useSettings } from "@/context/settings" import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -53,6 +57,31 @@ function FileCommentMenu(props: { ) } +function FileCommentMenuV2(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + + {props.editLabel} + {props.deleteLabel} + + + +
+ ) +} + + type ScrollPos = { x: number; y: number } function createScrollSync(input: { tab: () => string; view: ReturnType["view"] }) { @@ -180,6 +209,15 @@ export function FileTabContent(props: { tab: string }) { } export function SessionFileView(props: { tab: string }) { + const settings = useSettings() + return ( + }> + + + ) +} + +function SessionFileViewV1(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -463,3 +501,294 @@ export function SessionFileView(props: { tab: string }) { return content() } + +function SessionFileViewV2(props: { tab: string }) { + const file = useFile() + const comments = useComments() + const language = useLanguage() + const prompt = usePrompt() + const fileComponent = useFileComponent() + const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab + + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } + + const path = createMemo(() => file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => sampledChecksum(contents())) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null + return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null + }) + const scrollSync = createScrollSync({ + tab: () => props.tab, + view, + }) + + const selectionPreview = (source: string, selection: FileSelection) => { + return previewSelectedLines(source, { + start: selection.startLine, + end: selection.endLine, + }) + } + + const buildPreview = (filePath: string, selection: FileSelection) => { + const source = filePath === path() ? contents() : file.get(filePath)?.content?.content + if (!source) return undefined + return selectionPreview(source, selection) + } + + const addCommentToContext = (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => { + const selection = selectionFromLines(input.selection) + const preview = input.preview ?? buildPreview(input.file, selection) + + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) + prompt.context.add({ + type: "file", + path: input.file, + selection, + comment: input.comment, + commentID: saved.id, + commentOrigin: input.origin, + preview, + }) + } + + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + }) => { + comments.update(input.file, input.id, input.comment) + const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(preview ? { preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + selected: null as SelectedLineRange | null, + }) + + const syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) + } + + const activeSelection = () => note.selected ?? selectedLines() + + const commentsUi = createLineCommentControllerV2({ + comments: fileComments, + label: language.t("ui.lineComment.submit"), + draftKey: () => path() ?? props.tab, + mention: { + items: file.searchFilesAndDirectories, + }, + getSide: (range) => range.endSide ?? range.side ?? "additions", + state: { + opened: () => note.openedComment, + setOpened: (id) => setNote("openedComment", id), + selected: () => note.selected, + setSelected: (range) => setNote("selected", range), + commenting: () => note.commenting, + setCommenting: (range) => setNote("commenting", range), + syncSelected, + hoverSelected: syncSelected, + }, + onSubmit: ({ comment, selection }) => { + const p = path() + if (!p) return + addCommentToContext({ file: p, selection, comment, origin: "file" }) + }, + onUpdate: ({ id, comment, selection }) => { + const p = path() + if (!p) return + updateCommentInContext({ id, file: p, selection, comment }) + }, + onDelete: (comment) => { + const p = path() + if (!p) return + removeCommentFromContext({ id: comment.id, file: p }) + }, + editSubmitLabel: language.t("common.save"), + renderCommentActions: (_, controls) => ( + + ), + }) + + createEffect(() => { + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (activeFileTab() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return + + event.preventDefault() + event.stopPropagation() + find?.focus() + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + }) + + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + + createEffect(() => { + const focus = comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (activeFileTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) + requestAnimationFrame(() => comments.clearFocus()) + }) + + let prev = { + loaded: false, + ready: false, + active: false, + } + + createEffect(() => { + const loaded = !!state()?.loaded + const ready = file.ready() + const active = activeFileTab() === props.tab + const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) + prev = { loaded, ready, active } + if (!restore) return + scrollSync.queueRestore() + }) + + const renderFile = (source: string) => ( +
+ { + scrollSync.queueRestore() + }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderGutterUtility={commentsUi.renderGutterUtility} + onLineSelected={(range: SelectedLineRange | null) => { + commentsUi.onLineSelected(range) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + commentsUi.note.select(null) + commentsUi.note.cancelDraft() + return + } + commentsUi.onLineSelectionEnd(range) + }} + onLineNumberSelectionEnd={(range: SelectedLineRange | null) => { + commentsUi.onLineNumberSelectionEnd(range) + }} + search={search} + class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: scrollSync.queueRestore, + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} + /> +
+ ) + + const content = () => ( +
+ + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+
+ ) + + return content() +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index e009177682..6f6b4d2785 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,28 +1,46 @@ import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" +import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" +import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" @@ -53,6 +71,7 @@ export function SessionSidePanel(props: { reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element + reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void @@ -66,7 +85,9 @@ export function SessionSidePanel(props: { const language = useLanguage() const command = useCommand() const dialog = useDialog() + const sdk = useSDK() const { sessionKey, tabs, view, params } = useSessionLayout() + const projectDirectory = createMemo(() => sdk().directory) const isDesktop = createMediaQuery("(min-width: 768px)") const shown = settings.visibility.fileTree @@ -98,11 +119,9 @@ export function SessionSidePanel(props: { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") - const out = new Map() for (const diff of diffs()) { - const file = normalize(diff.file) + const file = normalizeFileTreeV2Path(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -159,6 +178,7 @@ export function SessionSidePanel(props: { fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen + const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab @@ -176,10 +196,8 @@ export function SessionSidePanel(props: { layout.fileTree.setTab("all") } - const [store, setStore] = createStore({ - activeDraggable: undefined as string | undefined, - }) let fileFilter: HTMLInputElement | undefined + let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview const previewTab = (value: string) => { const next = normalizeTab(value) @@ -202,10 +220,26 @@ export function SessionSidePanel(props: { } const browserTab = createMemo(() => { if (!props.fileBrowserState) return undefined - if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + const active = activeTab() + if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + if (active && file.pathFromTab(active)) return active return activeFileTab() }) - const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) + // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly + // selects Review while the tab For replaces a preview trigger, which would + // otherwise dispose the sidebar and reset scroll. + const fileBrowserMounted = createMemo(() => { + if (!props.fileBrowserState) return false + return openedTabs().length > 0 || openFileOpen() || !!browserTab() + }) + const fileBrowserVisible = createMemo(() => { + const active = activeTab() + return active !== "review" && active !== "context" && active !== "empty" + }) + const openFileKeybind = createMemo(() => command.keybindParts("file.open")) + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) const handleDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -291,72 +325,283 @@ export function SessionSidePanel(props: { "bg-background-base": !settings.general.newLayoutDesigns(), }} > - - - - -
- { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} > -
-
{language.t("session.tab.review")}
- -
{props.reviewCount()}
-
-
- - - - + +
+
{language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
+
+
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {(tab) => ( + + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+
+ )} +
+
+
tabs().close("context")} - aria-label={language.t("common.closeTab")} + iconSize="large" + class="!rounded-md" + onClick={() => { + void import("@/components/dialog-select-file").then((x) => { + dialog.show(() => ) + }) + }} + aria-label={language.t("command.file.open")} /> - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
- + +
+ + +
+ {props.reviewPanel()} +
- + + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+
+
+ + + +
+ +
+
+
+ + + {(tab) => } + + + + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ + {(p) => } + +
+ ) + }} +
+
+ + } + > + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > + +
+ { + tabList = el + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + {(toggle) => ( +
+ {toggle()(activeTab() === SESSION_OPEN_FILE_TAB)} +
+ )} +
+ + + {props.hasReview() + ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + : language.t("session.tab.review")} + + + + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
{(tab) => ( tabs().all().indexOf(tab)} temporary={temporaryTab() === tab} onTabClose={tabs().close} onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} @@ -392,106 +637,104 @@ export function SessionSidePanel(props: { )} - -
- - { - if (props.fileBrowserState) { - openFileBrowser() - return - } - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) - }} - aria-label={language.t("command.file.open")} - /> - + + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + } + variant="ghost-muted" + size="large" + onClick={() => openFileBrowser()} + aria-label={language.t("command.file.open")} + /> + +
+
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
- -
- - -
- {props.reviewPanel()}
-
- - -
-
- -
- {language.t("session.files.selectToOpen")} + +
+ {props.reviewPanel()} +
+
+ + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
-
- - + + - - -
- -
-
-
- - - previewTab(file.tab(path))} - onSelectPermanent={(path) => openTab(file.tab(path))} - filterRef={(element) => (fileFilter = element)} - /> - - - - {(tab) => } - - - - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- - {(p) => } - + + +
+
- ) - }} -
- - + + + + +
+ previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> +
+
+ + +
@@ -519,10 +762,21 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 + ? "session.review.change.one" + : "session.review.change.other", + )} + + } + > + {language.t("session.review.filesChanged", { count: props.reviewCount() })} + {language.t("session.files.all")} diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index a202a1e39d..143b4f5f52 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -5,7 +5,6 @@ import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SessionReviewV2, SessionReviewV2Sidebar, - SessionReviewV2SidebarToggle, } from "@opencode-ai/session-ui/v2/session-review-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" @@ -66,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { ) const searching = createMemo(() => props.state.filter().trim().length > 0) const kinds = createMemo(() => reviewDiffKinds(diffs())) + // Changes-only trees omit "M" — every row is already a change; A/D stay visible. + const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const activeDiff = createMemo(() => { // A focused comment takes over the preview until the preview applies it and // clears the focus; the owner then persists the file as the active selection. @@ -113,9 +114,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { stats={} empty={props.empty} sidebarOpen={props.state.sidebarOpened()} - sidebarToggle={ - - } sidebar={ // Always mounted: the sidebar header hosts the changes-mode dropdown, // which must stay reachable when the current mode has zero diffs. @@ -127,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffs={diffs} filteredFiles={filteredFiles} searching={searching} - kinds={kinds} + kinds={treeKinds} activeDiff={activeDiff} /> } diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 9cbdd40df1..04ad1e3c82 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -1,14 +1,9 @@ import { createMemo, createSignal, createUniqueId, Show } from "solid-js" import { createQuery } from "@tanstack/solid-query" -import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" -import { - SessionFilePanelV2, - SessionFilePanelV2Empty, - SessionFilePanelV2Title, -} from "@opencode-ai/session-ui/v2/session-file-panel-v2" -import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import FileTree, { type Kind } from "@/components/file-tree" +import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2" +import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" +import FileTreeV2, { type Kind } from "@/components/file-tree-v2" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -93,102 +88,92 @@ export function SessionFileBrowserTab(props: { }) } + // Keep the sidebar outside Kobalte Tabs.Content: a morphing content value + // unmounts the whole panel on every file-tab switch and resets sidebar scroll. return ( - - - - - {title()} - - - } - sidebar={ - {title()}} - filter={filter()} - onFilterChange={setFilter} - onFilterKeyDown={onFilterKeyDown} - filterAutofocus={props.placeholder} - filterRef={props.filterRef} - filterControls={resultsID} - filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} - filterExpanded={query().length > 0 && files().length > 0} - width={props.state.sidebarWidth()} - onWidthChange={props.state.resizeSidebar} + {title()}} + filter={filter()} + onFilterChange={setFilter} + onFilterKeyDown={onFilterKeyDown} + filterAutofocus={props.placeholder} + filterRef={props.filterRef} + filterControls={resultsID} + filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} + filterExpanded={query().length > 0 && files().length > 0} + width={props.state.sidebarWidth()} + onWidthChange={props.state.resizeSidebar} + > + props.onSelect(node.path)} + onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} + /> + } > props.onSelect(node.path)} - onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} - /> +
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
} > 0} fallback={
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} + {language.t("palette.empty")}
} > - 0} - fallback={ -
- {language.t("palette.empty")} -
- } - > - { - setExplicitHighlight(path) - props.onSelect(path) - }} - onFileDoubleClick={props.onSelectPermanent} - /> -
+ { + setExplicitHighlight(path) + props.onSelect(path) + }} + onFileDoubleClick={props.onSelectPermanent} + />
-
+ + + } + > + +
+ +
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+ } > - -
- -
{language.t("command.file.open")}
-
{language.t("session.files.selectToOpen")}
-
- - } - > -
- - {(tab) => } - -
-
-
-
+
+ + {(tab) => } + +
+ + ) } diff --git a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx index 4e8ed86732..cd27a1ecc7 100644 --- a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx +++ b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx @@ -11,6 +11,7 @@ import { import { useI18n } from "@opencode-ai/ui/context/i18n" import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge" import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2" +import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2" type LineCommentControllerV2Props = { comments: Accessor @@ -23,6 +24,7 @@ type LineCommentControllerV2Props = { onDelete?: (comment: T) => void renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element editSubmitLabel?: string + mention?: LineCommentEditorV2Mention } type CommentProps = { @@ -43,6 +45,7 @@ type DraftProps = { onSubmit: (value: string) => void cancelLabel?: string submitLabel?: string + mention?: LineCommentEditorV2Mention } function lineCommentElementV2(view: Accessor) { @@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor) { onSubmit={view().editor!.onSubmit} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} + mention={view().editor!.mention} />
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor) { onSubmit={view().onSubmit} cancelLabel={view().cancelLabel} submitLabel={view().submitLabel} + mention={view().mention} />
) @@ -142,6 +147,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: props.editSubmitLabel, + mention: props.mention, } : undefined }, @@ -165,6 +171,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: i18n.t("ui.lineComment.submit"), + mention: props.mention, }), }) @@ -202,6 +209,7 @@ export function createLineCommentControllerV2(props: } return { + note, annotations, renderAnnotation, renderGutterUtility, diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index b116ebb43f..9db2ba580c 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -76,6 +76,11 @@ color: var(--text-strong, var(--v2-text-text-base)); } +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) { + margin-left: 8px; +} + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] [data-component="select-v2-root"] { @@ -97,7 +102,7 @@ width: 100%; max-width: 100%; min-width: 0; - margin-top: 2px; + margin-top: 3px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 3c13c75d7b..6efb2b593a 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -26,7 +26,6 @@ export type SessionReviewV2Props = { empty?: JSX.Element sidebarOpen?: boolean sidebar?: JSX.Element - sidebarToggle?: JSX.Element activeFile?: string files: string[] onSelectFile: (file: string) => void @@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) { const toolbarStart = () => ( <> - {props.sidebarToggle}
@@ -319,7 +317,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { ) } -export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) { +export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) { const i18n = useI18n() return ( @@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} + disabled={props.disabled} onClick={props.onToggle} icon={} /> diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cd26b0cd50..9df9cbd83d 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -698,3 +698,99 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm } } } + +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 10px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 10px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + box-shadow: none; + } + } + + [data-slot="tabs-trigger"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-left: -2px; + } + } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { + width: 100%; + min-width: 0; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + background-color: var(--v2-background-bg-base); +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], +body[data-new-layout] + #review-panel + [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] { + flex: 1 1 0; + min-width: 0; + width: auto; + border-bottom: none; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"] > .sticky { + padding-right: 0; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { + flex-shrink: 0; + margin-left: auto; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { + flex-shrink: 0; +} diff --git a/packages/ui/src/v2/components/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css index e4236bcccd..ba88d09228 100644 --- a/packages/ui/src/v2/components/file-tree-v2.css +++ b/packages/ui/src/v2/components/file-tree-v2.css @@ -14,12 +14,14 @@ justify-content: flex-start; gap: 6px; padding-right: 8px; + overflow: visible; border: none; border-radius: 6px; background-color: transparent; color: var(--v2-text-text-muted); text-align: left; cursor: pointer; + scroll-margin-block: 8px; transition: background-color 120ms ease, color 120ms ease; @@ -42,6 +44,21 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] { + position: absolute; + top: 0; + margin-top: -2px; + height: calc(100% + 2px); + width: 1px; + pointer-events: none; + background-color: var(--v2-border-border-muted); + opacity: 0; +} + +[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] { + opacity: 0.5; +} + [data-component="file-tree-v2"] .filetree-icon--mono { color: var(--v2-icon-icon-muted); } @@ -108,7 +125,7 @@ } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] { - opacity: 0; + color: var(--v2-state-fg-info); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] { diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 31be02ae28..e46500469e 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -203,3 +203,56 @@ gap: 8px; flex-shrink: 0; } + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-list"] { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow: auto; + margin-top: 8px; + padding: 4px; + border: 0.5px solid var(--v2-border-border-muted); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"] { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: pointer; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"][data-active] { + background: var(--v2-background-bg-layer-03); +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-path"] { + display: flex; + align-items: center; + min-width: 0; + font-size: 12px; + line-height: 16px; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-dir"] { + min-width: 0; + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-file"] { + color: var(--v2-text-text-base); + white-space: nowrap; +} diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx index 29242fad31..48ec86b071 100644 --- a/packages/ui/src/v2/components/line-comment-v2.tsx +++ b/packages/ui/src/v2/components/line-comment-v2.tsx @@ -1,4 +1,6 @@ -import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js" +import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js" +import { FileIcon } from "../../components/file-icon" +import { useFilteredList } from "../../hooks" import { ButtonV2 } from "./button-v2" import "./line-comment-v2.css" @@ -53,6 +55,10 @@ export function LineCommentV2(props: LineCommentV2Props) { ) } +export type LineCommentEditorV2Mention = { + items: (query: string) => string[] | Promise +} + export interface LineCommentEditorV2Props extends Omit, "children" | "onInput" | "onSubmit"> { /** Visible field label above the textarea (default: “Comment”). */ heading?: JSX.Element | string @@ -66,10 +72,22 @@ export interface LineCommentEditorV2Props extends Omit, "c cancelLabel?: string submitLabel?: string autofocus?: boolean + mention?: LineCommentEditorV2Mention +} + +function pathFilename(path: string) { + const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + return index === -1 ? path : path.slice(index + 1) +} + +function pathDirectory(path: string) { + const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + return index === -1 ? "" : path.slice(0, index + 1) } export function LineCommentEditorV2(props: LineCommentEditorV2Props) { let textareaRef: HTMLTextAreaElement | undefined + const [mentionOpen, setMentionOpen] = createSignal(false) const [local, rest] = splitProps(props, [ "heading", @@ -83,6 +101,7 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { "cancelLabel", "submitLabel", "autofocus", + "mention", "class", "classList", ]) @@ -90,6 +109,78 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { const heading = () => local.heading ?? "Comment" const canSubmit = () => local.value.trim().length > 0 + const closeMention = () => { + setMentionOpen(false) + mention.clear() + } + + const currentMention = () => { + const textarea = textareaRef + if (!textarea) return + if (!local.mention) return + if (textarea.selectionStart !== textarea.selectionEnd) return + + const end = textarea.selectionStart + const match = textarea.value.slice(0, end).match(/@(\S*)$/) + if (!match) return + + return { + query: match[1] ?? "", + start: end - match[0].length, + end, + } + } + + function selectMention(item: { path: string } | undefined) { + if (!item) return + + const textarea = textareaRef + const query = currentMention() + if (!textarea || !query) return + + const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}` + const cursor = query.start + item.path.length + 2 + + local.onInput(value) + closeMention() + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + } + + const mention = useFilteredList<{ path: string }>({ + items: async (query) => { + if (!local.mention) return [] + if (!query.trim()) return [] + const paths = await local.mention.items(query) + return paths.map((path) => ({ path })) + }, + key: (item) => item.path, + filterKeys: ["path"], + skipFilter: () => true, + onSelect: selectMention, + }) + + const syncMention = () => { + const item = currentMention() + if (!item) { + closeMention() + return + } + + setMentionOpen(true) + mention.onInput(item.query) + } + + const selectActiveMention = () => { + const items = mention.flat() + if (items.length === 0) return + const active = mention.active() + selectMention(items.find((item) => item.path === active) ?? items[0]) + } + const submit = () => { const v = local.value.trim() if (!v) return @@ -122,9 +213,40 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { rows={local.rows ?? 3} placeholder={local.placeholder ?? "Add context for this change"} value={local.value} - onInput={(e) => local.onInput(e.currentTarget.value)} + onInput={(e) => { + local.onInput(e.currentTarget.value) + syncMention() + }} + onClick={() => syncMention()} + onSelect={() => syncMention()} onKeyDown={(e) => { e.stopPropagation() + if (e.isComposing || e.keyCode === 229) return + + if (mentionOpen()) { + if (e.key === "Escape") { + e.preventDefault() + closeMention() + return + } + + if (e.key === "Tab") { + if (mention.flat().length === 0) return + e.preventDefault() + selectActiveMention() + return + } + + const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter" + const ctrlNav = + e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p") + if ((nav || ctrlNav) && mention.flat().length > 0) { + mention.onKeyDown(e) + e.preventDefault() + return + } + } + if (e.key === "Escape") { e.preventDefault() e.currentTarget.blur() @@ -137,6 +259,34 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { } }} /> + 0}> +
+ + {(item) => { + const directory = item.path.endsWith("/") ? item.path : pathDirectory(item.path) + const name = item.path.endsWith("/") ? "" : pathFilename(item.path) + return ( + + ) + }} + +
+
{local.selection}
diff --git a/packages/ui/src/v2/components/split-button-v2.css b/packages/ui/src/v2/components/split-button-v2.css new file mode 100644 index 0000000000..e8469f494d --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.css @@ -0,0 +1,99 @@ +[data-component="split-button-v2"] { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + height: 28px; + border-radius: 6px; + background-color: transparent; + box-shadow: none; + overflow: hidden; +} + +[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) { + box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); +} + +[data-component="split-button-v2-action"], +[data-component="split-button-v2-menu-trigger"] { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--v2-icon-icon-base); + cursor: default; + outline: none; +} + +[data-component="split-button-v2-action"] { + width: 28px; + flex-shrink: 0; +} + +[data-component="split-button-v2-action"]:hover:not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-action"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-action"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-action"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-menu-trigger"] { + width: 20px; + flex-shrink: 0; +} + +[data-component="split-button-v2-menu-trigger"]:is(:hover, [data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-menu-trigger"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-menu-trigger"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-menu-trigger"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-action"] [data-component="app-icon"] { + width: 16px; + height: 16px; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="app-icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] [data-slot="icon-svg"] { + width: 16px; + height: 16px; +} diff --git a/packages/ui/src/v2/components/split-button-v2.tsx b/packages/ui/src/v2/components/split-button-v2.tsx new file mode 100644 index 0000000000..9585f8a4a7 --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.tsx @@ -0,0 +1,48 @@ +import { splitProps, type ComponentProps, type ParentProps } from "solid-js" +import "./split-button-v2.css" + +export function SplitButtonV2(props: ParentProps>) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} + +export function SplitButtonV2Action(props: ComponentProps<"button">) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( + + + +
) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 4e210c4edb..120eed726d 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -230,34 +230,53 @@ export const SettingsGeneralV2: Component<{ const InterfaceSection = () => (
- - - {language.t("settings.general.row.newInterface.title")} - {language.t("settings.general.row.newInterface.badge")} - - } - description={language.t("settings.general.row.newInterface.description", { - date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( - oldInterfaceSunset, - ), - })} - > -
- { - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - void dialog.show(() => ) - }) - }} - /> -
-
-
+
+ + + {language.t("settings.general.row.newInterface.title")} + {language.t("settings.general.row.newInterface.badge")} + + } + description={language.t("settings.general.row.newInterface.description", { + date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( + oldInterfaceSunset, + ), + })} + > +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + void dialog.show(() => ) + }) + }} + /> +
+
+
+
+ + + + + + {language.t("settings.general.row.newInterfaceNotice.dismiss")} + + + +
) diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index c36de6be9a..2b4b65e286 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -90,6 +90,11 @@ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); } +.settings-v2-interface-feature [data-component="settings-v2-list"] { + background-color: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-raised); +} + [data-component="settings-v2-row"] { display: flex; flex-wrap: wrap; @@ -128,7 +133,7 @@ } [data-slot="settings-v2-row-description"] { - font-size: 11px; + font-size: 13px; font-weight: 440; line-height: 1; color: var(--v2-text-text-muted); diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index da655f53a9..68c9e840e2 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -34,6 +34,7 @@ export interface Settings { showCustomAgents: boolean mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean + newInterfaceNoticeDismissed?: boolean } appearance: { fontSize: number @@ -254,6 +255,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setNewLayoutDesigns(value: boolean) { setStore("general", "newLayoutDesigns", value) }, + newInterfaceNoticeDismissed: withFallback( + () => store.general?.newInterfaceNoticeDismissed, + false, + ), + dismissNewInterfaceNotice() { + setStore("general", "newInterfaceNoticeDismissed", true) + }, }, visibility: { fileTree: visible(showFileTree), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 54df4d232c..808025cb14 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -855,9 +855,8 @@ export const dict = { "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", - "settings.general.row.shell.title": "Terminal Shell", - "settings.general.row.shell.description": - "Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.", + "settings.general.row.shell.title": "Terminal shell", + "settings.general.row.shell.description": "Shell used by the terminal and agent tools", "settings.general.row.shell.autoDefault": "Auto (Default)", "settings.general.row.shell.terminalOnly": "terminal only", "settings.general.row.appearance.title": "Appearance", @@ -889,8 +888,9 @@ export const dict = { "settings.general.row.mobileTitlebarBottom.title": "Bottom navigation", "settings.general.row.mobileTitlebarBottom.description": "Place the title bar and session tabs at the bottom of the screen on mobile", - "settings.general.row.showCustomAgents.title": "Custom agents", - "settings.general.row.showCustomAgents.description": "Show the agent picker in the composer", + "settings.general.row.showCustomAgents.title": "Show agent", + "settings.general.row.showCustomAgents.description": + "Switch between agents in the composer. When hidden, defaults to Build agent.", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", @@ -902,7 +902,10 @@ export const dict = { "settings.general.row.newInterface.title": "Use the new interface", "settings.general.row.newInterface.badge": "New", "settings.general.row.newInterface.description": - "A redesigned layout, tabs, composer and new home. Old interface goes away on {{date}}.", + "New layout, tabs, composer and home. Switch back until {{date}}.", + "settings.general.row.newInterfaceNotice.title": "You're now using the new interface", + "settings.general.row.newInterfaceNotice.description": "The old interface was phased out on {{date}}.", + "settings.general.row.newInterfaceNotice.dismiss": "Dismiss", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css index 44dfc7489d..5c7a59e0aa 100644 --- a/packages/ui/src/v2/components/badge-v2.css +++ b/packages/ui/src/v2/components/badge-v2.css @@ -29,5 +29,5 @@ [data-component="tag"][data-variant="accent"] { border: 0; background: var(--v2-background-bg-accent); - color: var(--v2-text-text-inverse); + color: var(--v2-text-text-contrast); } From 2b2985458927e313215321af366bc10f19ab2777 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 13 Jul 2026 10:04:14 +0000 Subject: [PATCH 16/38] chore: generate --- packages/app/src/components/settings-general.tsx | 1 - packages/app/src/context/settings.tsx | 5 +---- packages/app/src/i18n/en.ts | 3 +-- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 9968fa3b59..69dbbd1d5f 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -386,7 +386,6 @@ export const SettingsGeneral: Component = () => { />
-
) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 68c9e840e2..a25383597b 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -255,10 +255,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setNewLayoutDesigns(value: boolean) { setStore("general", "newLayoutDesigns", value) }, - newInterfaceNoticeDismissed: withFallback( - () => store.general?.newInterfaceNoticeDismissed, - false, - ), + newInterfaceNoticeDismissed: withFallback(() => store.general?.newInterfaceNoticeDismissed, false), dismissNewInterfaceNotice() { setStore("general", "newInterfaceNoticeDismissed", true) }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3ab9f2106a..8fcab1adc7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -901,8 +901,7 @@ export const dict = { "Show edit, write, and patch tool parts expanded by default in the timeline", "settings.general.row.newInterface.title": "Use the new interface", "settings.general.row.newInterface.badge": "New", - "settings.general.row.newInterface.description": - "New layout, tabs, composer and home. Switch back until {{date}}.", + "settings.general.row.newInterface.description": "New layout, tabs, composer and home. Switch back until {{date}}.", "settings.general.row.newInterfaceNotice.title": "You're now using the new interface", "settings.general.row.newInterfaceNotice.description": "The old interface was phased out on {{date}}.", "settings.general.row.newInterfaceNotice.dismiss": "Dismiss", From b4e49d5b328dea5b08b5286662ee4843c3da3e89 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:34:45 +0530 Subject: [PATCH 17/38] feat(app): redesign attachment cards (#35945) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- .../session-timeline-projection.spec.ts | 5 +- packages/app/src/components/prompt-input.tsx | 17 +- .../components/prompt-input/context-items.tsx | 16 +- .../prompt-input/image-attachments.css | 43 +++++ .../prompt-input/image-attachments.tsx | 155 +++++++++++++++--- packages/app/src/context/platform.tsx | 3 + packages/app/src/context/prompt-state.ts | 2 +- packages/app/src/context/prompt.tsx | 1 + packages/app/src/pages/session.tsx | 28 +++- .../session/timeline/message-timeline.tsx | 6 + .../src/pages/session/timeline/projection.ts | 2 + .../app/src/pages/session/timeline/rows.ts | 6 +- packages/desktop/src/main/ipc.ts | 10 ++ packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 1 + packages/desktop/src/renderer/index.tsx | 3 + .../src/components/message-file.test.ts | 12 +- .../session-ui/src/components/message-file.ts | 21 +++ .../src/components/message-part.css | 26 ++- .../src/components/message-part.tsx | 83 +++++++--- .../src/v2/components/attachment-card-v2.css | 58 +++++++ .../src/v2/components/attachment-card-v2.tsx | 26 +++ .../src/v2/components/comment-card-v2.tsx | 27 +++ .../.storybook/mocks/app/context/prompt.ts | 4 + packages/ui/src/v2/components/icon.tsx | 4 + 25 files changed, 502 insertions(+), 58 deletions(-) create mode 100644 packages/app/src/components/prompt-input/image-attachments.css create mode 100644 packages/session-ui/src/v2/components/attachment-card-v2.css create mode 100644 packages/session-ui/src/v2/components/attachment-card-v2.tsx create mode 100644 packages/session-ui/src/v2/components/comment-card-v2.tsx diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9fd2ca8d0b..b1aabcc32c 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -152,7 +152,10 @@ test.describe("session timeline projection", () => { parentID: "msg_2000_diff_next_user", created: 1700000011000, }) - await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) + await setupTimeline(page, { + messages: [user, assistantMessage(), nextUser, nextAssistant], + settings: { newLayoutDesigns: false }, + }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await scroller.evaluate((element) => (element.scrollTop = 0)) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3d6b83a9e9..7db0583c51 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f import { ContentPart, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -1626,7 +1627,7 @@ export const PromptInput: Component = (props) => { )} /> !isCommentItem(item))} active={(item) => { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -1636,6 +1637,7 @@ export const PromptInput: Component = (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} + comments={contextItems().filter(isCommentItem)} + commentActive={(item) => { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + onOpenComment={openComment} + onRemoveComment={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} />
= (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} />
boolean openComment: (item: PromptContextItem) => void remove: (item: PromptContextItem) => void + newLayoutDesigns: boolean t: (key: string) => string } @@ -27,10 +30,17 @@ export const PromptContextItems: Component = (props) => { const selected = props.active(item) return ( - - + {directory} {filename} @@ -78,7 +88,7 @@ export const PromptContextItems: Component = (props) => { {(comment) =>
{comment()}
}
- + ) }} diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css new file mode 100644 index 0000000000..2aaf494ebf --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.css @@ -0,0 +1,43 @@ +@keyframes prompt-attachments-fade-left { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes prompt-attachments-fade-right { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-slot="prompt-attachments"] { + timeline-scope: --prompt-attachments-scroll; + + [data-slot^="prompt-attachments-fade-"] { + visibility: hidden; + } +} + +@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) { + [data-slot="prompt-attachments-scroll"] { + scroll-timeline: --prompt-attachments-scroll x; + } + + [data-slot="prompt-attachments-fade-left"] { + animation: prompt-attachments-fade-left linear both; + animation-timeline: --prompt-attachments-scroll; + animation-range: 0 0.1px; + } + + [data-slot="prompt-attachments-fade-right"] { + animation: prompt-attachments-fade-right linear both; + animation-timeline: --prompt-attachments-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index dd8138e5a4..3a0cc3e4dd 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -1,60 +1,167 @@ import { Component, For, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import type { ImageAttachmentPart } from "@/context/prompt" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2" +import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2" +import { typeLabel } from "@opencode-ai/session-ui/message-file" +import type { ContextItem, ImageAttachmentPart } from "@/context/prompt" +import "./image-attachments.css" + +type PromptCommentItem = ContextItem & { key: string } type PromptImageAttachmentsProps = { attachments: ImageAttachmentPart[] onOpen: (attachment: ImageAttachmentPart) => void onRemove: (id: string) => void removeLabel: string + newLayoutDesigns: boolean + comments?: PromptCommentItem[] + commentActive?: (item: PromptCommentItem) => boolean + onOpenComment?: (item: PromptCommentItem) => void + onRemoveComment?: (item: PromptCommentItem) => void } const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" const imageClass = "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover" +// inset box-shadows do not paint over content, so the hairline is a separate overlay +const imageHairlineClassV2 = + "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" const removeClass = "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" +const removeClassV2 = + "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" export const PromptImageAttachments: Component = (props) => { return ( - 0}> -
- - {(attachment) => ( - -
+ 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}> +
+
+ + + {(item) => ( +
+ + props.onOpenComment?.(item)} + /> + + +
+ )} +
+
+ + {(attachment) => { + const image = attachment.mime.startsWith("image/") + const media = () => ( - -
+ + +
+ } + > + + {typeLabel(attachment.filename, attachment.mime)} + +
} > {attachment.filename} props.onOpen(attachment)} /> - + ) + const name = () => (
{attachment.filename}
-
-
- )} -
+ ) + const remove = () => ( + + ) + // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip + return ( + +
+ {media()} + {name()} + {remove()} +
+ + } + > +
+ + {media()} + +
+ + + {remove()} +
+
+ ) + }} + +
+ +
+
+
) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index e59bec9aaf..1a061a6675 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -37,6 +37,9 @@ type PlatformBase = { /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ + revealPath?(path: string): Promise + /** Restart the app */ restart(): Promise diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index b6a68be4e7..462cfa638b 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { +export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { return item.type === "file" && !!item.comment?.trim() } diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 0ad8e70f3c..14147b6616 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -24,6 +24,7 @@ export { createPromptSession, createPromptState, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, } from "./prompt-state" export type { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8bf7a058ca..7ce4627135 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,5 @@ -import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { @@ -1905,7 +1906,30 @@ export default function Page() { .map((item) => ({ id: item.id, text: line(item.id) })) }) - const actions = { revert } + // attachment bytes are embedded as a data URL, so downloading always works; + // revealing requires the on-disk path captured by the client that attached the file + const openAttachment = (file: FilePart) => { + const download = () => { + const anchor = document.createElement("a") + anchor.href = file.url + anchor.download = getFilename(file.filename) || "attachment" + anchor.click() + } + const path = file.filename ?? "" + const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path) + if (platform.revealPath && absolute) { + void platform.revealPath(path).then( + (revealed) => { + if (!revealed) download() + }, + () => download(), + ) + return + } + download() + } + + const actions = { revert, openAttachment } createEffect(() => { const sessionID = params.id diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index a7390b6d61..8a1e9c5fae 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -327,6 +327,7 @@ export function MessageTimeline(props: { parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, + inlineComments: settings.general.newLayoutDesigns, }) const activeMessageID = projection.activeMessageID const assistantMessagesByParent = projection.assistantMessagesByParent @@ -1135,6 +1136,10 @@ export function MessageTimeline(props: { const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) + const messageComments = createMemo(() => { + if (!settings.general.newLayoutDesigns()) return [] + return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []) + }) return ( @@ -1146,6 +1151,7 @@ export function MessageTimeline(props: { parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} useV2Actions={settings.general.newLayoutDesigns()} + comments={messageComments()} />
diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ec2a3190ba..ea8ea4f132 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -14,6 +14,7 @@ export function createTimelineProjection(input: { parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor + inlineComments: Accessor }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { @@ -59,6 +60,7 @@ export function createTimelineProjection(input: { input.showReasoningSummaries(), input.status().type, activeMessageID() === userMessage.id, + input.inlineComments(), ), ), ), diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 6a9cb669d0..72cd9a180c 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -38,6 +38,8 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus["type"], isActive: boolean, + // v2 renders comments inside the user message attachments row instead of a strip row + inlineComments: boolean, ) { const rows: TimelineRow.TimelineRow[] = [] @@ -74,7 +76,7 @@ export namespace Timeline { : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) - if (comments.length > 0) + if (comments.length > 0 && !inlineComments) rows.push( new TimelineRow.CommentStrip({ userMessageID: userMessage.id, @@ -84,7 +86,7 @@ export namespace Timeline { rows.push( new TimelineRow.UserMessage({ userMessageID: userMessage.id, - anchor: comments.length === 0, + anchor: inlineComments || comments.length === 0, }), ) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 073d288e5c..d6bc5bb3a8 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) { }) }) + ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { + const exists = await stat(path).then( + () => true, + () => false, + ) + if (!exists) return false + shell.showItemInFolder(path) + return true + }) + ipcMain.handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 47a757a557..a12864c559 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -95,6 +95,7 @@ const api: ElectronAPI = { saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), openLink: (url) => ipcRenderer.send("open-link", url), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), + revealPath: (path) => ipcRenderer.invoke("reveal-path", path), readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index b57ac83e7f..b94acd6615 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -86,6 +86,7 @@ export type ElectronAPI = { saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void openPath: (path: string, app?: string) => Promise + revealPath: (path: string) => Promise readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 966fb0c0bf..2f14f43481 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { } return window.api.openPath(path, app) }, + async revealPath(path: string) { + return window.api.revealPath(path) + }, back() { window.history.back() diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index 7bdf007631..3882be027e 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { FilePart } from "@opencode-ai/sdk/v2" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" function file(part: Partial = {}): FilePart { return { @@ -52,4 +52,14 @@ describe("message-file", () => { expect(kind(file({ mime: "image/png" }))).toBe("image") expect(kind(file({ mime: "application/pdf" }))).toBe("file") }) + + test("labels attachment types from the basename extension", () => { + expect(typeLabel("list.md", "text/plain")).toBe("Markdown") + expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript") + expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF") + expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ") + expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File") + expect(typeLabel(".gitignore", "text/plain")).toBe("File") + expect(typeLabel("/repo/.env", "text/plain")).toBe("File") + }) }) diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index ecc7456902..81ce97827f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -1,3 +1,5 @@ +import { bundledLanguagesInfo } from "shiki" +import { getFilename } from "@opencode-ai/core/util/path" import type { FilePart } from "@opencode-ai/sdk/v2" export function attached(part: FilePart) { @@ -12,3 +14,22 @@ export function inline(part: FilePart) { export function kind(part: FilePart) { return part.mime.startsWith("image/") ? "image" : "file" } + +// language metadata only; grammars stay behind shiki's lazy imports +const LANGUAGE_NAMES = new Map( + bundledLanguagesInfo.flatMap((info) => + [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]), + ), +) + +// attachments carry text/plain for all text files, so the label comes from the extension; +// filename may be an absolute path, so extract the basename before looking for one +export function typeLabel(filename: string, mime: string) { + if (mime === "application/pdf") return "PDF" + const base = getFilename(filename) + // idx 0 is a dotfile like .gitignore, not an extension + const idx = base.lastIndexOf(".") + const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase() + if (!suffix) return "File" + return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase() +} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 88779a3c6c..e298ba7abc 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -57,8 +57,20 @@ } &[data-type="image"] { - width: 48px; - height: 48px; + position: relative; + width: 58px; + height: 46px; + border: none; + + /* inset box-shadows do not paint over content, so the hairline is an overlay */ + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); + pointer-events: none; + } } &[data-type="file"] { @@ -1323,6 +1335,16 @@ body:not([data-new-layout]) { &:hover { border-color: var(--border-strong-base); } + + &[data-type="image"] { + width: 48px; + height: 48px; + border: 1px solid var(--border-weak-base); + + &::after { + content: none; + } + } } [data-slot="user-message-attachment-name"] { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 1ba6be82c8..6d1d399e62 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -47,6 +47,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { AttachmentCardV2 } from "../v2/components/attachment-card-v2" +import { CommentCardV2 } from "../v2/components/comment-card-v2" import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -60,7 +62,7 @@ import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" @@ -167,6 +169,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean useV2Actions?: boolean + comments?: UserMessageComment[] } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -174,6 +177,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) => export type UserActions = { fork?: SessionAction revert?: SessionAction + openAttachment?: (file: FilePart) => void +} + +export type UserMessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } } export interface MessagePartProps { @@ -946,6 +959,7 @@ export function Message(props: MessageProps) { parts={props.parts} actions={props.actions} useV2Actions={props.useV2Actions} + comments={props.comments} /> )} @@ -1163,6 +1177,7 @@ export function UserMessageDisplay(props: { parts: PartType[] actions?: UserActions useV2Actions?: boolean + comments?: UserMessageComment[] }) { const data = useData() const dialog = useDialog() @@ -1184,6 +1199,8 @@ export function UserMessageDisplay(props: { const attachments = createMemo(() => files().filter(attached)) + const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : [])) + const inlineFiles = createMemo(() => files().filter(inline)) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) @@ -1240,35 +1257,59 @@ export function UserMessageDisplay(props: { return (
- 0}> + 0 || messageComments().length > 0}>
+ + {(comment) => ( + + )} + {(file) => { const type = kind(file) const name = file.filename ?? i18n.t("ui.message.attachment.alt") return ( -
{ - if (type === "image") openImagePreview(file.url, name) - }} + { + if (type === "image") openImagePreview(file.url, name) + }} + > + + + {name} +
+ } + > + {name} + +
+ } > - - - {name} -
- } + props.actions?.openAttachment?.(file)} > - {name} -
-
+ {typeLabel(name, file.mime)} + +
) }} diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.css b/packages/session-ui/src/v2/components/attachment-card-v2.css new file mode 100644 index 0000000000..1a19dc7b5e --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.css @@ -0,0 +1,58 @@ +[data-component="attachment-card-v2"] { + display: flex; + flex-direction: column; + gap: 6px; + box-sizing: border-box; + width: 160px; + min-width: 160px; + max-width: 160px; + padding: 8px; + border-radius: 6px; + background: var(--v2-overlay-simple-overlay-hover); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); + cursor: default; + + &[data-active] { + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong); + } + + &[data-clickable] { + cursor: pointer; + } + + [data-slot="attachment-card-v2-title"], + [data-slot="attachment-card-v2-subtitle"] { + max-width: 100%; + font-family: var(--v2-font-family-sans, "Inter", sans-serif); + font-style: normal; + font-size: 11px; + line-height: 12px; + letter-spacing: 0.05px; + font-variation-settings: "slnt" 0; + } + + [data-slot="attachment-card-v2-title"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 530; + color: var(--v2-text-text-base); + } + + [data-slot="attachment-card-v2-subtitle"] { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + font-weight: 440; + color: var(--v2-text-text-muted); + + [data-component="file-icon"] { + width: 12px; + height: 12px; + flex: none; + } + } +} diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.tsx b/packages/session-ui/src/v2/components/attachment-card-v2.tsx new file mode 100644 index 0000000000..2e896383a5 --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.tsx @@ -0,0 +1,26 @@ +import type { JSX } from "solid-js" +import "./attachment-card-v2.css" + +/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */ +export function AttachmentCardV2(props: { + title: string + active?: boolean + clickable?: boolean + /** native title attribute */ + hover?: string + onClick?: () => void + children: JSX.Element +}) { + return ( +
props.onClick?.()} + > + {props.title} + {props.children} +
+ ) +} diff --git a/packages/session-ui/src/v2/components/comment-card-v2.tsx b/packages/session-ui/src/v2/components/comment-card-v2.tsx new file mode 100644 index 0000000000..fa2fe08c32 --- /dev/null +++ b/packages/session-ui/src/v2/components/comment-card-v2.tsx @@ -0,0 +1,27 @@ +import { Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getFilenameTruncated } from "@opencode-ai/core/util/path" +import { AttachmentCardV2 } from "./attachment-card-v2" + +export function CommentCardV2(props: { + comment: string + path: string + selection?: { startLine: number; endLine: number } + active?: boolean + title?: string + onClick?: () => void +}) { + return ( + + + + {getFilenameTruncated(props.path, 14)} + + {(sel) => + sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}` + } + + + + ) +} diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts index 1ea1fc7983..c41ba553fe 100644 --- a/packages/storybook/.storybook/mocks/app/context/prompt.ts +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) { return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i])) } +export function isCommentItem(item: ContextItem) { + return !!item.comment?.trim() +} + export function createPromptState() { const [store, setStore] = createStore({ prompt: clonePrompt(DEFAULT_PROMPT), diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index da402fd4d9..b22d3140fe 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -89,6 +89,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-xmark": { + viewBox: "0 0 16 16", + body: ``, + }, "outline-chevron-down": { viewBox: "0 0 16 16", body: ``, From 51b9c726cf21c007328cbe5324d312dc95875caf Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:38:30 +0200 Subject: [PATCH 18/38] fix(app): remove interface transition changes accidentally merged into dev (#36653) --- .../app/src/components/settings-general.tsx | 74 +++++------------- .../src/components/settings-v2/general.tsx | 76 +++++-------------- .../components/settings-v2/settings-v2.css | 7 +- packages/app/src/context/settings.tsx | 7 -- packages/app/src/i18n/ar.ts | 7 +- packages/app/src/i18n/br.ts | 7 +- packages/app/src/i18n/bs.ts | 7 +- packages/app/src/i18n/da.ts | 7 +- packages/app/src/i18n/de.ts | 7 +- packages/app/src/i18n/en.ts | 18 ++--- packages/app/src/i18n/es.ts | 7 +- packages/app/src/i18n/fr.ts | 7 +- packages/app/src/i18n/ja.ts | 7 +- packages/app/src/i18n/ko.ts | 6 +- packages/app/src/i18n/no.ts | 7 +- packages/app/src/i18n/pl.ts | 7 +- packages/app/src/i18n/ru.ts | 7 +- packages/app/src/i18n/th.ts | 7 +- packages/app/src/i18n/tr.ts | 7 +- packages/app/src/i18n/uk.ts | 7 +- packages/app/src/i18n/zh.ts | 5 +- packages/app/src/i18n/zht.ts | 5 +- packages/ui/src/v2/components/badge-v2.css | 6 -- .../ui/src/v2/components/badge-v2.stories.tsx | 7 +- packages/ui/src/v2/components/badge-v2.tsx | 7 +- 25 files changed, 95 insertions(+), 221 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 69dbbd1d5f..c424a62a89 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useParams } from "@solidjs/router" @@ -24,7 +23,6 @@ import { sansInput, terminalDefault, terminalFontFamily, - oldInterfaceSunset, terminalInput, useSettings, } from "@/context/settings" @@ -250,56 +248,6 @@ export const SettingsGeneral: Component = () => { triggerVariant: "settings" as const, }) - const InterfaceSection = () => ( -
- - - {language.t("settings.general.row.newInterface.title")} - {language.t("settings.general.row.newInterface.badge")} - - } - description={language.t("settings.general.row.newInterface.description", { - date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( - oldInterfaceSunset, - ), - })} - > -
- { - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - void dialog.show(() => ) - }) - }} - /> -
-
-
- - - - - - - - -
- ) - const GeneralSection = () => (
@@ -386,6 +334,24 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
) @@ -752,10 +718,6 @@ export const SettingsGeneral: Component = () => {
- - - - diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 120eed726d..69dfb77fb0 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,6 +1,5 @@ import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" @@ -22,7 +21,6 @@ import { sansInput, terminalDefault, terminalFontFamily, - oldInterfaceSunset, terminalInput, useSettings, } from "@/context/settings" @@ -228,58 +226,6 @@ export const SettingsGeneralV2: Component<{ }, }) - const InterfaceSection = () => ( -
-
- - - {language.t("settings.general.row.newInterface.title")} - {language.t("settings.general.row.newInterface.badge")} - - } - description={language.t("settings.general.row.newInterface.description", { - date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format( - oldInterfaceSunset, - ), - })} - > -
- { - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - void dialog.show(() => ) - }) - }} - /> -
-
-
-
- - - - - - {language.t("settings.general.row.newInterfaceNotice.dismiss")} - - - - -
- ) - const GeneralSection = () => (
@@ -366,6 +312,24 @@ export const SettingsGeneralV2: Component<{
+ +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
+
- - - - diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 2b4b65e286..c36de6be9a 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -90,11 +90,6 @@ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); } -.settings-v2-interface-feature [data-component="settings-v2-list"] { - background-color: var(--v2-background-bg-base); - box-shadow: var(--v2-elevation-raised); -} - [data-component="settings-v2-row"] { display: flex; flex-wrap: wrap; @@ -133,7 +128,7 @@ } [data-slot="settings-v2-row-description"] { - font-size: 13px; + font-size: 11px; font-weight: 440; line-height: 1; color: var(--v2-text-text-muted); diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index a25383597b..8d4f61079c 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -34,7 +34,6 @@ export interface Settings { showCustomAgents: boolean mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean - newInterfaceNoticeDismissed?: boolean } appearance: { fontSize: number @@ -54,8 +53,6 @@ export const monoDefault = "System Mono" export const sansDefault = "System Sans" export const terminalDefault = "JetBrainsMono Nerd Font Mono" export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" -// Date the old interface is retired; the settings toggle references this countdown. -export const oldInterfaceSunset = new Date(2026, 7, 6) const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' @@ -255,10 +252,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setNewLayoutDesigns(value: boolean) { setStore("general", "newLayoutDesigns", value) }, - newInterfaceNoticeDismissed: withFallback(() => store.general?.newInterfaceNoticeDismissed, false), - dismissNewInterfaceNotice() { - setStore("general", "newInterfaceNoticeDismissed", true) - }, }, visibility: { fileTree: visible(showFileTree), diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 0c0c568932..2b639eded7 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -735,10 +735,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.description": "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", - "settings.general.row.newInterface.title": "استخدام الواجهة الجديدة", - "settings.general.row.newInterface.badge": "جديد", - "settings.general.row.newInterface.description": - "تخطيط وعلامات تبويب ومحرر رسائل وصفحة رئيسية معاد تصميمها. ستختفي الواجهة السابقة في {{date}}.", + "settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة", + "settings.general.row.newLayoutDesigns.description": + "تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها", "settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص", "settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index fdf487a8c4..688b05fee1 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -746,10 +746,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.description": "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", - "settings.general.row.newInterface.title": "Usar a nova interface", - "settings.general.row.newInterface.badge": "Novo", - "settings.general.row.newInterface.description": - "Layout, abas, área de composição e página inicial reformulados. A interface anterior será removida em {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Novo layout e design", + "settings.general.row.newLayoutDesigns.description": + "Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados", "settings.general.row.pinchZoom.title": "Zoom com gesto de pinça", "settings.general.row.pinchZoom.description": "Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index ae804ff8d1..e0d4e2aa03 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -811,10 +811,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.description": "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", - "settings.general.row.newInterface.title": "Koristi novi interfejs", - "settings.general.row.newInterface.badge": "Novo", - "settings.general.row.newInterface.description": - "Redizajnirani raspored, kartice, uređivač poruke i nova početna stranica. Stari interfejs nestaje {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn", + "settings.general.row.newLayoutDesigns.description": + "Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije", "settings.general.row.pinchZoom.title": "Zumiranje štipanjem", "settings.general.row.pinchZoom.description": "Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 47d710414f..3b02b6a026 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -804,10 +804,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", - "settings.general.row.newInterface.title": "Brug den nye brugerflade", - "settings.general.row.newInterface.badge": "Ny", - "settings.general.row.newInterface.description": - "Et nydesignet layout, faner, promptfelt og ny startside. Den gamle brugerflade forsvinder den {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Nyt layout og design", + "settings.general.row.newLayoutDesigns.description": + "Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome", "settings.general.row.wayland.title": "Brug native Wayland", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index c969e3523c..cf530f6488 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -759,10 +759,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.description": "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", - "settings.general.row.newInterface.title": "Neue Oberfläche verwenden", - "settings.general.row.newInterface.badge": "Neu", - "settings.general.row.newInterface.description": - "Neu gestaltetes Layout, Tabs, Eingabebereich und neue Startseite. Die alte Oberfläche wird am {{date}} entfernt.", + "settings.general.row.newLayoutDesigns.title": "Neues Layout und Design", + "settings.general.row.newLayoutDesigns.description": + "Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren", "settings.general.row.pinchZoom.title": "Zoom per Fingergeste", "settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben", "settings.general.row.wayland.title": "Natives Wayland verwenden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8fcab1adc7..1cfdc51782 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -855,8 +855,9 @@ export const dict = { "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", - "settings.general.row.shell.title": "Terminal shell", - "settings.general.row.shell.description": "Shell used by the terminal and agent tools", + "settings.general.row.shell.title": "Terminal Shell", + "settings.general.row.shell.description": + "Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.", "settings.general.row.shell.autoDefault": "Auto (Default)", "settings.general.row.shell.terminalOnly": "terminal only", "settings.general.row.appearance.title": "Appearance", @@ -888,9 +889,8 @@ export const dict = { "settings.general.row.mobileTitlebarBottom.title": "Bottom navigation", "settings.general.row.mobileTitlebarBottom.description": "Place the title bar and session tabs at the bottom of the screen on mobile", - "settings.general.row.showCustomAgents.title": "Show agent", - "settings.general.row.showCustomAgents.description": - "Switch between agents in the composer. When hidden, defaults to Build agent.", + "settings.general.row.showCustomAgents.title": "Custom agents", + "settings.general.row.showCustomAgents.description": "Show the agent picker in the composer", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", @@ -899,12 +899,8 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.description": "Show edit, write, and patch tool parts expanded by default in the timeline", - "settings.general.row.newInterface.title": "Use the new interface", - "settings.general.row.newInterface.badge": "New", - "settings.general.row.newInterface.description": "New layout, tabs, composer and home. Switch back until {{date}}.", - "settings.general.row.newInterfaceNotice.title": "You're now using the new interface", - "settings.general.row.newInterfaceNotice.description": "The old interface was phased out on {{date}}.", - "settings.general.row.newInterfaceNotice.dismiss": "Dismiss", + "settings.general.row.newLayoutDesigns.title": "New layout and designs", + "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index db3b0e356f..0610f2de00 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -816,10 +816,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.description": "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", - "settings.general.row.newInterface.title": "Usar la nueva interfaz", - "settings.general.row.newInterface.badge": "Nuevo", - "settings.general.row.newInterface.description": - "Un diseño renovado con pestañas, editor y nueva página de inicio. La interfaz anterior desaparecerá el {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos", + "settings.general.row.newLayoutDesigns.description": + "Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones", "settings.general.row.pinchZoom.title": "Pellizcar para ampliar", "settings.general.row.pinchZoom.description": "Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f51a7d2562..3ed47d5dba 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -756,10 +756,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.description": "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", - "settings.general.row.newInterface.title": "Utiliser la nouvelle interface", - "settings.general.row.newInterface.badge": "Nouveau", - "settings.general.row.newInterface.description": - "Une mise en page repensée, des onglets, une zone de saisie et un nouvel accueil. L'ancienne interface disparaîtra le {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs", + "settings.general.row.newLayoutDesigns.description": + "Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session", "settings.general.row.pinchZoom.title": "Pincer pour zoomer", "settings.general.row.pinchZoom.description": "Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 0c89569d43..52ec83b3c4 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -741,10 +741,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.description": "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", - "settings.general.row.newInterface.title": "新しいインターフェースを使用", - "settings.general.row.newInterface.badge": "新機能", - "settings.general.row.newInterface.description": - "刷新されたレイアウト、タブ、コンポーザー、ホーム画面です。以前のインターフェースは{{date}}に終了します。", + "settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン", + "settings.general.row.newLayoutDesigns.description": + "刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします", "settings.general.row.pinchZoom.title": "ピンチでズーム", "settings.general.row.pinchZoom.description": "トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index de4d1337e3..738123fe03 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -999,10 +999,8 @@ export const dict = { "settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치", "settings.general.row.showCustomAgents.title": "사용자 지정 에이전트", "settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시", - "settings.general.row.newInterface.title": "새 인터페이스 사용", - "settings.general.row.newInterface.badge": "신규", - "settings.general.row.newInterface.description": - "새롭게 디자인된 레이아웃, 탭, 입력창과 새 홈 화면입니다. 이전 인터페이스는 {{date}}에 종료됩니다.", + "settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인", + "settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화", "settings.general.row.pinchZoom.title": "핀치 줌", "settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용", "settings.updates.action.downloading": "다운로드 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c8e17c8f96..0a37f8bf32 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -1095,10 +1095,9 @@ export const dict = { "Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen", "settings.general.row.showCustomAgents.title": "Egendefinerte agenter", "settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet", - "settings.general.row.newInterface.title": "Bruk det nye grensesnittet", - "settings.general.row.newInterface.badge": "Ny", - "settings.general.row.newInterface.description": - "Et redesignet oppsett med faner, skrivefelt og ny startside. Det gamle grensesnittet forsvinner {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Ny layout og utforming", + "settings.general.row.newLayoutDesigns.description": + "Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet", "settings.general.row.pinchZoom.title": "Knip for å zoome", "settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome", "settings.updates.action.downloading": "Laster ned...", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 52b303a589..f56e023aed 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -746,10 +746,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.description": "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", - "settings.general.row.newInterface.title": "Użyj nowego interfejsu", - "settings.general.row.newInterface.badge": "Nowość", - "settings.general.row.newInterface.description": - "Przeprojektowany układ, karty, edytor i nowa strona główna. Poprzedni interfejs zniknie {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd", + "settings.general.row.newLayoutDesigns.description": + "Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji", "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index ccc092b9d6..0d68552e6d 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -812,10 +812,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.description": "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", - "settings.general.row.newInterface.title": "Использовать новый интерфейс", - "settings.general.row.newInterface.badge": "Новое", - "settings.general.row.newInterface.description": - "Обновлённая компоновка, вкладки, редактор запросов и новая главная страница. Прежний интерфейс исчезнет {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление", + "settings.general.row.newLayoutDesigns.description": + "Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии", "settings.general.row.pinchZoom.title": "Масштабирование щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e2594fe29b..91c009be0c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -801,10 +801,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.description": "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", - "settings.general.row.newInterface.title": "ใช้อินเทอร์เฟซใหม่", - "settings.general.row.newInterface.badge": "ใหม่", - "settings.general.row.newInterface.description": - "เลย์เอาต์ แท็บ ช่องเขียนข้อความ และหน้าแรกที่ออกแบบใหม่ อินเทอร์เฟซเดิมจะเลิกใช้งานในวันที่ {{date}}", + "settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่", + "settings.general.row.newLayoutDesigns.description": + "เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่", "settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม", "settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 97757b4aa5..ff17d0001c 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -816,10 +816,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", "settings.general.row.editToolPartsExpanded.description": "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.newInterface.title": "Yeni arayüzü kullan", - "settings.general.row.newInterface.badge": "Yeni", - "settings.general.row.newInterface.description": - "Yeniden tasarlanan düzen, sekmeler, yazma alanı ve yeni ana sayfa. Eski arayüz {{date}} tarihinde kaldırılacak.", + "settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar", + "settings.general.row.newLayoutDesigns.description": + "Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir", "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", "settings.general.row.pinchZoom.description": "İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 3652bd587d..c6a0ae9629 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -902,10 +902,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.description": "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", - "settings.general.row.newInterface.title": "Використовувати новий інтерфейс", - "settings.general.row.newInterface.badge": "Нове", - "settings.general.row.newInterface.description": - "Оновлений макет, вкладки, редактор запитів і нова головна сторінка. Попередній інтерфейс зникне {{date}}.", + "settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн", + "settings.general.row.newLayoutDesigns.description": + "Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії", "settings.general.row.pinchZoom.title": "Масштабування щипком", "settings.general.row.pinchZoom.description": "Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d2d0540f20..a44bfc107f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -796,9 +796,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", - "settings.general.row.newInterface.title": "使用新界面", - "settings.general.row.newInterface.badge": "新", - "settings.general.row.newInterface.description": "重新设计的布局、标签页、输入框和主页。旧界面将于{{date}}停用。", + "settings.general.row.newLayoutDesigns.title": "新版布局和设计", + "settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面", "settings.general.row.pinchZoom.title": "双指缩放", "settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放", "settings.general.row.wayland.title": "使用原生 Wayland", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 10076d0bb9..e9a1fea29e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -792,9 +792,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", - "settings.general.row.newInterface.title": "使用新介面", - "settings.general.row.newInterface.badge": "新", - "settings.general.row.newInterface.description": "重新設計的版面、分頁、輸入區和首頁。舊介面將於{{date}}停用。", + "settings.general.row.newLayoutDesigns.title": "新版面與設計", + "settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面", "settings.general.row.pinchZoom.title": "雙指縮放", "settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放", "settings.general.row.wayland.title": "使用原生 Wayland", diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css index 5c7a59e0aa..748c08a711 100644 --- a/packages/ui/src/v2/components/badge-v2.css +++ b/packages/ui/src/v2/components/badge-v2.css @@ -25,9 +25,3 @@ [data-component="tag"][data-high-contrast] { border-color: var(--v2-border-border-strong); } - -[data-component="tag"][data-variant="accent"] { - border: 0; - background: var(--v2-background-bg-accent); - color: var(--v2-text-text-contrast); -} diff --git a/packages/ui/src/v2/components/badge-v2.stories.tsx b/packages/ui/src/v2/components/badge-v2.stories.tsx index 19af508bc2..084838c0e1 100644 --- a/packages/ui/src/v2/components/badge-v2.stories.tsx +++ b/packages/ui/src/v2/components/badge-v2.stories.tsx @@ -8,11 +8,10 @@ Use alongside headings or lists for quick metadata. ### API - Accepts standard span props. -- Optional: \`variant\` is \`neutral\` (default) or \`accent\`. - Optional: \`data-high-contrast\` attribute for stronger border contrast. ### Variants and states -- Neutral and accent variants. +- Single size style. - Optional high-contrast border style. ### Behavior @@ -53,7 +52,3 @@ export const HighContrast = {
), } - -export const Accent = { - render: () => New, -} diff --git a/packages/ui/src/v2/components/badge-v2.tsx b/packages/ui/src/v2/components/badge-v2.tsx index 8583d46da9..3bb06259e1 100644 --- a/packages/ui/src/v2/components/badge-v2.tsx +++ b/packages/ui/src/v2/components/badge-v2.tsx @@ -1,17 +1,14 @@ import { type ComponentProps, splitProps } from "solid-js" import "./badge-v2.css" -export interface TagProps extends ComponentProps<"span"> { - variant?: "neutral" | "accent" -} +export interface TagProps extends ComponentProps<"span"> {} export function Tag(props: TagProps) { - const [split, rest] = splitProps(props, ["class", "classList", "children", "variant"]) + const [split, rest] = splitProps(props, ["class", "classList", "children"]) return ( Date: Mon, 13 Jul 2026 10:59:16 +0000 Subject: [PATCH 19/38] fix(cli): switch org after console logout (#36276) Co-authored-by: Victor Navarro --- packages/opencode/src/account/account.ts | 14 +++++- .../opencode/test/account/service.test.ts | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index af8ef761eb..4b49d2a748 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -348,6 +348,18 @@ const layer: Layer.Layer + group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })), + )[0] + if (!next) return + yield* repo.use(next.accountID, Option.some(next.orgID)) + }) + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { const resolved = yield* resolveAccess(accountID) if (Option.isNone(resolved)) return Option.none() @@ -445,7 +457,7 @@ const layer: Layer.Layer }), ) +it.live("remove switches to another org when the active account is removed", () => + Effect.gen(function* () { + const first = AccountID.make("user-1") + const second = AccountID.make("user-2") + + yield* AccountRepo.Service.use((r) => + r.persistAccount({ + id: first, + email: "one@example.com", + url: "https://one.example.com", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.some(OrgID.make("org-1")), + }), + ) + + yield* AccountRepo.Service.use((r) => + r.persistAccount({ + id: second, + email: "two@example.com", + url: "https://two.example.com", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.some(OrgID.make("org-2")), + }), + ) + + const client = HttpClient.make((req) => + Effect.succeed( + req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404), + ), + ) + + yield* Account.use.remove(second).pipe(Effect.provide(live(client))) + + const active = yield* AccountRepo.use.active() + expect(Option.getOrThrow(active)).toEqual( + expect.objectContaining({ + id: first, + active_org_id: OrgID.make("org-1"), + }), + ) + }), +) + it.live("token refresh persists the new token", () => Effect.gen(function* () { const id = AccountID.make("user-1") From 99668cfdcebeb52a357c9269aa59dd97428c27b0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:18:32 -0500 Subject: [PATCH 20/38] fix(provider): tweak reasoning option driven max budget variants (#36626) --- packages/opencode/src/provider/transform.ts | 12 +++---- .../opencode/test/provider/transform.test.ts | 36 ++++++++++++++++--- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 73caf5ab6b..46ce4d9d3d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1611,17 +1611,13 @@ function effortVariants(model: Provider.Model, values: readonly unknown[]) { } function budgetVariants(model: Provider.Model, min?: number, max?: number) { - const limit = model.limit.output - 1 - if (limit <= 0) return {} - const high = Math.min( - max === undefined ? Math.max(min ?? 0, 16_000) : Math.min(Math.max(min ?? 0, 16_000), max), - limit, - ) - const maximum = max === undefined ? undefined : Math.min(max, limit) + const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + if (maximum <= 0) return {} + const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum) return Object.fromEntries( [ { id: "high", budget: high }, - ...(maximum === undefined || maximum === high ? [] : [{ id: "max", budget: maximum }]), + { id: "max", budget: maximum }, ].flatMap((item) => { const settings = reasoningBudget(model, item.budget) return settings ? [[item.id, settings]] : [] diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index a144997405..a6ebad986e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3111,9 +3111,9 @@ describe("ProviderTransform.reasoningVariants", () => { ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], ])("converts token budgets for %s", (npm, high) => { - expect( - ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 16_000 }]), target(npm)), - ).toEqual({ high }) + const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm)) + expect(variants?.high).toEqual(high) + expect(Object.keys(variants ?? {})).toEqual(["high", "max"]) }) test("maps null effort to none", () => { @@ -3150,6 +3150,7 @@ describe("ProviderTransform.reasoningVariants", () => { ).toEqual({ none: { thinking: { type: "disabled" } }, high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, + max: { thinking: { type: "enabled", tokenBudget: 31_999 } }, }) }) @@ -3161,7 +3162,7 @@ describe("ProviderTransform.reasoningVariants", () => { ), ).toEqual({ high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, - max: { thinking: { type: "enabled", budgetTokens: 63_999 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, }) }) @@ -3171,7 +3172,32 @@ describe("ProviderTransform.reasoningVariants", () => { expect( ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), ).toEqual({ - high: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + high: { thinking: { type: "enabled", budgetTokens: 2_500 } }, + max: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + }) + }) + + test("derives high and max budgets when models.dev omits max", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024 }]), + target("@ai-sdk/anthropic", "claude-haiku-4-5"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("preserves explicit inclusive budget maxima", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]), + target("@ai-sdk/google", "gemini-2.5-pro"), + ), + ).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, }) }) From 5401ebaededec3e2b6c1f2e0d20246ef68574598 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 13 Jul 2026 16:31:20 +0200 Subject: [PATCH 21/38] chore: bump gitlab-ai-provider to 6.11.0 (#36668) --- bun.lock | 6 +++--- packages/core/package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 2f31450ed0..3e9cbb84de 100644 --- a/bun.lock +++ b/bun.lock @@ -338,7 +338,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -631,7 +631,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3850,7 +3850,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.11.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-/LTD5qtSbGGnWd7/HB72YioSMNYqF7RlABojBqRqZ8UK5DpxoTp/dnT7K/tIScDWMYPGpJqX/B7JIOh0xeXFLQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/core/package.json b/packages/core/package.json index faac818690..1587a7eddf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 89574054c3..442460f18b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -120,7 +120,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", From 7ec3d67829cf01ab0c32a3319d296a991cb231d8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 13 Jul 2026 14:50:37 +0000 Subject: [PATCH 22/38] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 50208fdbbe..d3d3e556fa 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", - "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", - "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", - "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" + "x86_64-linux": "sha256-QlwoEmN1jiw9TqQgOutPOv4mEMCNUR0Mm3ivYIWlc1o=", + "aarch64-linux": "sha256-KS8unXRXev29zbPskvp2ddtfAKXJN8VfInXnmPqypBI=", + "aarch64-darwin": "sha256-T3tgbuodPitCx2U5JJIQFLkRguAFeiMM7flTsF2H5Iw=", + "x86_64-darwin": "sha256-aITMNkQpFYAChDz5jZCchFkSylsDhJJQoyPu9k23Hl8=" } } From 67aa9cee23f9aac19477e714cce7db937a0e4eb9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:15:06 -0500 Subject: [PATCH 23/38] fix(opencode): support Luna Responses Lite over OAuth (#36685) Co-authored-by: AidenGeunGeun --- packages/opencode/src/plugin/openai/codex.ts | 64 +++++++++++++++++++ .../opencode/src/plugin/openai/ws-pool.ts | 13 +++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index ea9bba4344..d1aa060eb6 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -6,12 +6,15 @@ import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { isRecord } from "@/util/record" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 +const CODEX_COMPATIBILITY_VERSION = "0.144.0" +const RESPONSES_LITE_MODEL = "gpt-5.6-luna" const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) @@ -417,6 +420,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug const requestInit = { ...init, + body: parsed.pathname.endsWith("/responses") + ? prepareResponsesLiteRequest(init?.body, headers) + : init?.body, headers, } if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) @@ -561,3 +567,61 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug }, } } + +function prepareResponsesLiteRequest(body: BodyInit | null | undefined, headers: Headers) { + if (typeof body !== "string") return body + const request: unknown = JSON.parse(body) + if (!isRecord(request)) return body + if (request.model !== RESPONSES_LITE_MODEL) return body + if (!Array.isArray(request.input)) throw new Error("Responses Lite requires an input array") + if (request.tools !== undefined && !Array.isArray(request.tools)) { + throw new Error("Responses Lite requires a tools array") + } + if (request.instructions !== undefined && typeof request.instructions !== "string") { + throw new Error("Responses Lite requires string instructions") + } + + const sessionID = headers.get("session-id") + if (!sessionID) throw new Error("Responses Lite requires a session-id header") + + function stripImageDetail(value: unknown) { + if (Array.isArray(value)) { + value.forEach(stripImageDetail) + return + } + if (!isRecord(value)) return + if (value.type === "input_image") delete value.detail + Object.values(value).forEach(stripImageDetail) + } + + stripImageDetail(request.input) + request.input = [ + { type: "additional_tools", role: "developer", tools: request.tools ?? [] }, + ...(request.instructions + ? [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: request.instructions }], + }, + ] + : []), + ...request.input, + ] + delete request.tools + delete request.instructions + request.tool_choice = "auto" + request.parallel_tool_calls = false + request.prompt_cache_key = sessionID + request.reasoning = { + ...(isRecord(request.reasoning) ? request.reasoning : {}), + context: "all_turns", + } + + headers.set("session-id", sessionID) + headers.set("x-session-affinity", sessionID) + headers.set("version", CODEX_COMPATIBILITY_VERSION) + headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true") + headers.delete("content-length") + return JSON.stringify(request) +} diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 3cbb29a301..2cc7dcc940 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -4,6 +4,8 @@ import { isRecord } from "@/util/record" import { OpenAIWebSocket } from "./ws" export const TITLE_HEADER = "x-opencode-title" +export const RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite" +const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite" export interface CreateWebSocketFetchOptions { httpFetch?: typeof globalThis.fetch @@ -98,7 +100,16 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { }) const response = OpenAIWebSocket.streamResponsesWebSocket({ socket: entry.socket, - body, + body: + internalHeaders[RESPONSES_LITE_HEADER] === "true" + ? { + ...body, + client_metadata: { + ...(isRecord(body.client_metadata) ? body.client_metadata : {}), + [RESPONSES_LITE_CLIENT_METADATA]: "true", + }, + } + : body, idleTimeout, signal: init?.signal ?? undefined, onFirstEvent: (error) => resolveFirstEvent(error ?? true), From 49d997aec36153681ad871adb06be38f14dc2d33 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jul 2026 08:37:12 -0700 Subject: [PATCH 24/38] fix(xai): default store to false for Responses (#36629) Co-authored-by: Aiden Cline --- packages/opencode/src/provider/transform.ts | 6 ++- .../opencode/test/provider/transform.test.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 46ce4d9d3d..2b9b8d5930 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1102,7 +1102,8 @@ export function options(input: { input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/github-copilot" || - input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" || + input.model.api.npm === "@ai-sdk/xai" ) { result["store"] = false } @@ -1257,7 +1258,8 @@ export function smallOptions(model: Provider.Model) { if ( model.providerID === "openai" || model.api.npm === "@ai-sdk/openai" || - model.api.npm === "@ai-sdk/github-copilot" + model.api.npm === "@ai-sdk/github-copilot" || + model.api.npm === "@ai-sdk/xai" ) { const base = { store: false } return mergeDeep(base, small) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index a6ebad986e..4c462bd3dc 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -156,6 +156,43 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) + test("should set store=false for xAI provider by default", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set store=false for xAI SDK regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + test("should set store=false for azure provider by default", () => { const azureModel = { ...mockModel, From 92cc8f553a96899f375957eb4625f85495e05e91 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:52:43 -0500 Subject: [PATCH 25/38] fix(stats): correct breadcrumb behavior --- .../stats/app/src/routes/[lab]/[model].tsx | 17 ++++-- packages/stats/app/src/routes/[lab]/index.tsx | 4 +- .../stats/app/src/routes/breadcrumb-menu.ts | 52 +++++++++++++++++++ packages/stats/app/src/routes/index.css | 20 ++++++- 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 packages/stats/app/src/routes/breadcrumb-menu.ts diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 7d14ec3d01..1209975cce 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -39,6 +39,7 @@ import { uniqueComparisonPairs, type ComparisonModelRef, } from "../compare-cards" +import { createBreadcrumbMenuRoot } from "../breadcrumb-menu" import { applyThemePreference, Footer, @@ -284,9 +285,10 @@ function ModelHero(props: { const labModels = () => props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ?? (props.catalog ? [props.catalog] : []) + const menuRoot = createBreadcrumbMenuRoot() return (
-