From 5cf24bf1855da18cfe47c0144b64fdab7ff1bd1e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 13 Jul 2026 21:04:36 -0400 Subject: [PATCH 01/17] tui: prevent crash when opening session switcher from an active session --- packages/tui/src/ui/dialog-select.tsx | 5 ++--- .../tui/test/cli/tui/dialog-select.test.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 40ad0f24b1..1d225dffc0 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -155,11 +155,10 @@ export function DialogSelect(props: DialogSelectProps) { .filter((item) => item.label), ...(props.footerHints ?? []), ]) - const actionItems = createMemo(() => + const actionItems = () => visibleActions() .filter(isActionItem) - .filter((item) => !isActionDisabled(item)), - ) + .filter((item) => !isActionDisabled(item)) createEffect(() => { const index = focusedAction() diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 96c849186d..c9404706d8 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -16,6 +16,7 @@ async function renderSelect( options: DialogSelectOption[], onGlobal: () => void, onRow: (option: DialogSelectOption) => void, + current?: string, ) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) @@ -52,6 +53,7 @@ async function renderSelect( []) return { app, moved, replaceOptions, selected } } +test("renders actions with a current selection", async () => { + await using tmp = await tmpdir() + const app = await renderSelect( + tmp.path, + [{ title: "Alpha", value: "alpha" }], + () => {}, + () => {}, + "alpha", + ) + + try { + await app.waitForFrame((frame) => frame.includes("delete")) + } finally { + app.renderer.destroy() + } +}) + test("dialog actions run without options while row actions still require a selection", async () => { await using tmp = await tmpdir() let global = 0 From 2a08cd3b96702c08cad58ad5727e996996cad440 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 13 Jul 2026 22:45:15 -0400 Subject: [PATCH 02/17] refactor(core): simplify plugin entrypoint resolution --- packages/core/src/npm.ts | 32 +++++++++------ packages/core/src/plugin/supervisor.ts | 40 +------------------ .../fixtures/plugins/folder-plugin/index.ts | 13 ------ packages/core/test/config/plugin.test.ts | 6 +-- packages/core/test/npm.test.ts | 16 ++++++-- 5 files changed, 35 insertions(+), 72 deletions(-) delete mode 100644 packages/core/test/config/fixtures/plugins/folder-plugin/index.ts diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff12..48e71da064 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -25,7 +25,10 @@ export interface EntryPoint { } export interface Interface { - readonly add: (pkg: string) => Effect.Effect + readonly add: ( + pkg: string, + options?: { readonly subpaths?: readonly string[] }, + ) => Effect.Effect readonly install: ( dir: string, input?: { @@ -47,13 +50,18 @@ export function sanitize(pkg: string) { return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } -const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: string | undefined - try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - } catch { - entrypoint = undefined - } +const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => { + const entrypoint = subpaths + .map((subpath) => { + try { + return typeof Bun !== "undefined" + ? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir) + : import.meta.resolve(dir) + } catch { + return undefined + } + }) + .find((entrypoint) => entrypoint !== undefined) return { directory: dir, entrypoint, @@ -112,7 +120,7 @@ const layer = Layer.effect( }), ) - const add = Effect.fn("Npm.add")(function* (pkg: string) { + const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) { const dir = directory(pkg) const name = (() => { try { @@ -123,17 +131,17 @@ const layer = Layer.effect( })() if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { - return resolveEntryPoint(name, path.join(dir, "node_modules", name)) + return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to if (!first) { - const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths) if (result.entrypoint) return result return yield* new InstallFailedError({ add: [pkg], dir }) } - return resolveEntryPoint(first.name, first.path) + return resolveEntryPoint(first.name, first.path, options?.subpaths) }, Effect.scoped) const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) { diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index c29c6a8647..f41be2ad7c 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -54,12 +54,6 @@ const PluginModule = Schema.Struct({ ]), }) -const PluginPackage = Schema.Struct({ - exports: Schema.optional(Schema.Unknown), - main: Schema.optional(Schema.String), - module: Schema.optional(Schema.String), -}) - type Operation = | { readonly type: "add" @@ -165,7 +159,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract [])) - const directories = yield* fs - .glob("{plugin,plugins}/*", { - cwd: directory, - absolute: true, - include: "all", - dot: true, - symlink: true, - }) - .pipe( - Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })), - Effect.orElseSucceed(() => []), - ) - const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), { - concurrency: "unbounded", - }).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) - return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} })) + return files.sort().map((target): Operation => ({ type: "add", target, options: {} })) }) } -const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { - const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), - Effect.catch(() => Effect.succeed(undefined)), - ) - const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined - const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] - - return yield* Effect.forEach(entries, (entry) => { - if (!entry) return Effect.succeed(undefined) - const file = path.resolve(directory, entry) - return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) - }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) -}) - export interface Interface { /** Wait for the initial plugin generation and startup updates to settle. */ readonly flush: Effect.Effect diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts deleted file mode 100644 index 365d5566d2..0000000000 --- a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Plugin } from "@opencode-ai/plugin/v2" - -export default Plugin.define({ - id: "folder-plugin", - setup: async (ctx) => { - await ctx.agent.transform((agents) => { - agents.update("folder", (agent) => { - agent.description = "Loaded from plugin folder" - agent.mode = "subagent" - }) - }) - }, -}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 4c83d3bb4b..626e94f758 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -134,7 +134,7 @@ describe("PluginSupervisor config", () => { ), ) - it.live("loads auto-discovered plugin files and packages", () => + it.live("loads auto-discovered plugin files", () => withLocation( undefined, Effect.gen(function* () { @@ -143,9 +143,6 @@ describe("PluginSupervisor config", () => { expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({ description: "Loaded from plugin directory", }) - expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({ - description: "Loaded from plugin folder", - }) }), true, ), @@ -195,7 +192,6 @@ describe("PluginSupervisor config", () => { yield* ready() const agents = yield* AgentV2.Service expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined() - expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined() }), true, ), diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf..3a2a956299 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -41,19 +41,27 @@ describe("Npm.add", () => { await fs.mkdir(path.join(tmp.path, "fixture-provider")) await writePackage(path.join(tmp.path, "fixture-provider"), { name: "fixture-provider", - main: "index.js", + exports: { + ".": "./index.js", + "./tui": "./tui.js", + }, }) await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n") const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) - const entry = await Effect.gen(function* () { + const entries = await Effect.gen(function* () { const npm = yield* Npm.Service - return yield* npm.add(spec) + return { + tui: yield* npm.add(spec, { subpaths: ["tui", ""] }), + fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }), + } }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(entry.entrypoint).toBeDefined() + expect(entries.tui.entrypoint).toEndWith("/tui.js") + expect(entries.fallback.entrypoint).toEndWith("/index.js") }) }) From 9b8282ad3db97bd2f4dc7ba2dede5aaebbd9b2fc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 13 Jul 2026 23:08:34 -0400 Subject: [PATCH 03/17] feat(plugin): add v2 tui entrypoint --- packages/plugin/package.json | 2 +- packages/plugin/src/v2/tui/context.ts | 2 +- packages/plugin/src/v2/tui/index.ts | 1 + packages/plugin/src/v2/tui/plugin.ts | 14 ++++++++++++++ packages/plugin/test/contract-identity.test.ts | 6 ++++++ 5 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 packages/plugin/src/v2/tui/index.ts create mode 100644 packages/plugin/src/v2/tui/plugin.ts diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 65b6bcb9d6..69a6a3417b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,7 +15,7 @@ "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/*": "./src/v2/effect/*.ts", - "./v2/tui/*": "./src/v2/tui/*.ts", + "./v2/tui": "./src/v2/tui/index.ts", "./v2": "./src/v2/promise/index.ts", "./v2/*": "./src/v2/promise/*.ts" }, diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index 5989bb34dc..ad0d782b61 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -105,7 +105,7 @@ export interface UI { } export interface Context { - readonly options: Record + readonly options: Readonly> readonly client: OpenCodeClient readonly data: Data readonly ui: UI diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts new file mode 100644 index 0000000000..8020216abc --- /dev/null +++ b/packages/plugin/src/v2/tui/index.ts @@ -0,0 +1 @@ +export * as Plugin from "./plugin.js" diff --git a/packages/plugin/src/v2/tui/plugin.ts b/packages/plugin/src/v2/tui/plugin.ts new file mode 100644 index 0000000000..2ab1e4faad --- /dev/null +++ b/packages/plugin/src/v2/tui/plugin.ts @@ -0,0 +1,14 @@ +import type { Context } from "./context.js" + +export type { Context } + +export type Cleanup = () => Promise | void + +export interface Definition { + readonly id: string + readonly setup: (context: Context) => Promise | Cleanup | void +} + +export function define(plugin: Definition) { + return plugin +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 79e97a22f7..243a7aa89b 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -11,6 +11,7 @@ import { Skill } from "@opencode-ai/schema/skill" const Plugin = await import("../src/v2/effect/index") const PromisePlugin = await import("../src/v2/promise/index") +const TuiPlugin = await import("../src/v2/tui/index") test.each([ ["effect", Plugin], @@ -38,3 +39,8 @@ test.each([ "Skill", ]) }) + +test("tui entrypoint exposes the V2 plugin definition", () => { + const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} }) + expect(plugin.id).toBe("demo") +}) From aa8fc4234da087fbbbc29d26f06e35f795298ec3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:59:22 +0200 Subject: [PATCH 04/17] fix(core): add session correlation header (#36842) Co-authored-by: Dustin Deus --- packages/core/src/session/runner/llm.ts | 8 ++++++++ packages/core/test/session-runner.test.ts | 16 ++++++++++++++++ packages/tui/src/context/data.tsx | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d18351b53e..6a15137c07 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -51,6 +51,7 @@ import { AgentNotFoundError, StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { PluginSupervisor } from "../../plugin/supervisor" +import { Flag } from "../../flag/flag" type StepTokens = { readonly input: number @@ -197,6 +198,13 @@ const layer = Layer.effect( const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, + http: { + headers: { + "x-opencode-project": session.projectID, + "x-opencode-session": session.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }, + }, providerOptions: { openai: { promptCacheKey } }, system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial] .filter((part): part is string => part !== undefined && part.length > 0) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0ff2a86e84..ed1c1b3e21 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -19,6 +19,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { Flag } from "@opencode-ai/core/flag/flag" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -3043,6 +3044,21 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("adds session correlation headers to model requests", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Run correlated request") + + yield* session.resume(sessionID) + + expect(requests[0]?.http?.headers).toEqual({ + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 66ca56fcc9..114da13a1f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -27,7 +27,7 @@ import type { SkillInfo, OpenCodeEvent, } from "@opencode-ai/client" -import type { Data } from "@opencode-ai/plugin/v2/tui/context" +import type { Plugin } from "@opencode-ai/plugin/v2/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" @@ -1108,7 +1108,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, } - result satisfies Data + result satisfies Plugin.Context["data"] async function bootstrap() { if (bootstrapping) return bootstrapping From ea386fba1f0088e90168ccece0f7cad8d61db84d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 14 Jul 2026 19:32:29 +0530 Subject: [PATCH 05/17] feat(llm): add compatible responses provider (#35908) --- packages/llm/AGENTS.md | 4 +- packages/llm/README.md | 5 +- packages/llm/STATUS.md | 65 ++++++++++--------- packages/llm/package.json | 2 + packages/llm/src/protocols/index.ts | 1 + .../protocols/openai-compatible-responses.ts | 23 +++++++ packages/llm/src/providers/index.ts | 1 + .../providers/openai-compatible-responses.ts | 55 ++++++++++++++++ packages/llm/test/exports.test.ts | 15 ++++- packages/llm/test/provider-package.test.ts | 29 ++++++++- .../openai-compatible-responses.test.ts | 53 +++++++++++++++ 11 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 packages/llm/src/protocols/openai-compatible-responses.ts create mode 100644 packages/llm/src/providers/openai-compatible-responses.ts create mode 100644 packages/llm/test/provider/openai-compatible-responses.test.ts diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index e883a9e47d..1d920258df 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -164,9 +164,11 @@ packages/llm/src/ bedrock-converse.ts bedrock-event-stream.ts framing for AWS event-stream binary frames openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL + openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) providers/ - openai-compatible.ts generic compatible helper + family model helpers + openai-compatible.ts generic Chat helper + family model helpers + openai-compatible-responses.ts generic Responses helper openai-compatible-profile.ts family defaults (deepseek, togetherai, ...) azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts tool.ts typed tool() helper diff --git a/packages/llm/README.md b/packages/llm/README.md index 477d01701a..3a3aa0c389 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -104,7 +104,7 @@ const gateway = CloudflareAIGateway.configure({ }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") ``` -Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. +Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. and a generic Responses entrypoint. ### Package-like entrypoints @@ -125,8 +125,9 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints: - `@opencode-ai/llm/providers/openai/chat` - `@opencode-ai/llm/providers/openai/responses` +- `@opencode-ai/llm/providers/openai-compatible/responses` -Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. +Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Anthropic, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md index 2994e59453..11d0025d20 100644 --- a/packages/llm/STATUS.md +++ b/packages/llm/STATUS.md @@ -13,20 +13,21 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A ## Current Implementation Snapshot -| Native slice | Source | Current state | Main gaps | -| ---------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | -| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | -| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | -| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | No OpenAI-compatible Responses protocol/facade. Family quirks are mostly endpoint defaults, not full typed behavior. | -| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | -| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | -| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | -| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | -| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | -| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | -| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | -| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | +| Native slice | Source | Current state | Main gaps | +| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | +| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | +| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | +| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | +| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. | +| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | +| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | +| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | +| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | +| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | +| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | +| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | +| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | ## V2 Runner Status @@ -45,7 +46,7 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh | AI SDK package | Intended native target | Status | Biggest gaps | | --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. | -| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat plus future Responses | Partial | Add OpenAI-compatible Responses. Decide per-family namespace/profile behavior for providers that support Responses versus Chat only. | +| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. | | `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | | `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. | @@ -57,38 +58,38 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh ## Highest-Risk Gaps 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. -2. OpenAI-compatible is Chat-only. We need a separate OpenAI-compatible Responses slice for providers/deployments that expose `/responses`, not an overloaded Chat route. +2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. 4. Vertex is not implemented natively. Google Gemini Developer API exists, but Vertex Gemini and Vertex Anthropic are separate auth/endpoint products and should be separate namespaces/facades. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. ## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| API slice | Package-like entrypoint | Purpose | -| --------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | -| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | -| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | -| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. | -| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | -| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | -| Vertex Gemini | Missing | Vertex Gemini API. | -| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | -| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | -| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | -| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | -| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | +| API slice | Package-like entrypoint | Purpose | +| --------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | `@opencode-ai/llm/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. | +| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | Missing | Vertex Gemini API. | +| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices 1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close. -2. Implement `OpenAICompatibleResponses` as a separate protocol/route/facade instead of extending Chat. +2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. 4. Add Vertex Gemini and Vertex Anthropic native facades with ADC/OAuth auth and project/location endpoint derivation. 5. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. diff --git a/packages/llm/package.json b/packages/llm/package.json index d9a8a8b7eb..8e1c674494 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -28,6 +28,7 @@ "./providers/openai/responses": "./src/providers/openai/responses.ts", "./providers/openai/chat": "./src/providers/openai/chat.ts", "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai-compatible/responses": "./src/providers/openai-compatible-responses.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", "./providers/xai": "./src/providers/xai.ts", @@ -37,6 +38,7 @@ "./protocols/gemini": "./src/protocols/gemini.ts", "./protocols/openai-chat": "./src/protocols/openai-chat.ts", "./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts", + "./protocols/openai-compatible-responses": "./src/protocols/openai-compatible-responses.ts", "./protocols/openai-responses": "./src/protocols/openai-responses.ts" }, "devDependencies": { diff --git a/packages/llm/src/protocols/index.ts b/packages/llm/src/protocols/index.ts index bd8c8d3d9d..d00d517a09 100644 --- a/packages/llm/src/protocols/index.ts +++ b/packages/llm/src/protocols/index.ts @@ -3,4 +3,5 @@ export * as BedrockConverse from "./bedrock-converse" export * as Gemini from "./gemini" export * as OpenAIChat from "./openai-chat" export * as OpenAICompatibleChat from "./openai-compatible-chat" +export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/openai-compatible-responses.ts b/packages/llm/src/protocols/openai-compatible-responses.ts new file mode 100644 index 0000000000..2c56aadafa --- /dev/null +++ b/packages/llm/src/protocols/openai-compatible-responses.ts @@ -0,0 +1,23 @@ +import { Route, type RouteRoutedModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { OpenAIResponses } from "./openai-responses" + +const ADAPTER = "openai-compatible-responses" + +export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput + +/** + * Route for providers that expose an OpenAI Responses-compatible `/responses` + * endpoint. Provider helpers configure identity, endpoint, and auth before + * model selection while this route reuses the OpenAI Responses protocol. + */ +export const route = Route.make({ + id: ADAPTER, + providerMetadataKey: "openai", + protocol: OpenAIResponses.protocol, + endpoint: Endpoint.path(OpenAIResponses.PATH), + transport: OpenAIResponses.httpTransport, + defaults: { providerOptions: { openai: { store: false } } }, +}) + +export * as OpenAICompatibleResponses from "./openai-compatible-responses" diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts index 774274cf2d..042e825a51 100644 --- a/packages/llm/src/providers/index.ts +++ b/packages/llm/src/providers/index.ts @@ -7,5 +7,6 @@ export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" export * as OpenAI from "./openai" export * as OpenAICompatible from "./openai-compatible" +export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenRouter from "./openrouter" export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible-responses.ts b/packages/llm/src/providers/openai-compatible-responses.ts new file mode 100644 index 0000000000..58b8ab65ae --- /dev/null +++ b/packages/llm/src/providers/openai-compatible-responses.ts @@ -0,0 +1,55 @@ +import type { ProviderPackage } from "../provider-package" +import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import type { OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("openai-compatible") + +export type Config = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly provider?: string + readonly baseURL: string + } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export const routes = [OpenAICompatibleResponses.route] + +export const configure = (input: Config) => { + const provider = input.provider ?? "openai-compatible" + const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input + const route = OpenAICompatibleResponses.route.with({ + ...rest, + provider, + endpoint: { baseURL }, + auth: AuthOptions.bearer(input, []), + }) + return { + id: ProviderID.make(provider), + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 0d25cc3dba..0ac6eb9bf2 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -11,7 +11,12 @@ import { XAI, } from "@opencode-ai/llm/providers" import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" -import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import { + OpenAIChat, + OpenAICompatibleChat, + OpenAICompatibleResponses, + OpenAIResponses, +} from "@opencode-ai/llm/protocols" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" describe("public exports", () => { @@ -28,12 +33,17 @@ describe("public exports", () => { expect(Protocol.make).toBeFunction() }) - test("provider barrels expose user-facing facades", () => { + test("provider barrels expose user-facing facades", async () => { + const { OpenAICompatibleResponses } = await import("@opencode-ai/llm/providers") + expect(OpenAI.model).toBeFunction() expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction() + expect( + OpenAICompatibleResponses.configure({ baseURL: "https://responses.test/v1" }).model("fixture").route.id, + ).toBe("openai-compatible-responses") expect(CloudflareAIGateway.configure).toBeFunction() expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction() @@ -68,6 +78,7 @@ describe("public exports", () => { test("protocol barrels expose supported low-level routes", () => { expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") expect(AnthropicMessages.route.id).toBe("anthropic-messages") diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index 346b6b86ee..0428c02eec 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -9,6 +9,7 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/openai/chat"), import("@opencode-ai/llm/providers/anthropic"), import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/openai-compatible/responses"), import("@opencode-ai/llm/providers/amazon-bedrock"), import("@opencode-ai/llm/providers/azure"), import("@opencode-ai/llm/providers/azure/responses"), @@ -18,7 +19,7 @@ describe("provider package entrypoints", () => { for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) - expect(modules[6].model).toBe(modules[7].model) + expect(modules[7].model).toBe(modules[8].model) }) test("maps package settings onto the executable model", () => { @@ -42,6 +43,32 @@ describe("provider package entrypoints", () => { expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket") }) + test("maps OpenAI-compatible Responses settings onto the executable model", async () => { + const OpenAICompatibleResponses = await import("@opencode-ai/llm/providers/openai-compatible/responses") + const selected = OpenAICompatibleResponses.model("custom-model", { + apiKey: "fixture", + baseURL: "https://responses.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + providerOptions: { openai: { reasoningEffort: "low", store: true } }, + }) + + expect(String(selected.provider)).toBe("example") + expect(selected.route.id).toBe("openai-compatible-responses") + expect(selected.route.endpoint).toMatchObject({ + baseURL: "https://responses.example.test/v1", + path: "/responses", + }) + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(selected.route.defaults.providerOptions).toEqual({ + openai: { reasoningEffort: "low", store: true }, + }) + }) + test("maps legacy OpenAI organization and project settings to headers", () => { const selected = model("gpt-5", { apiKey: "fixture", diff --git a/packages/llm/test/provider/openai-compatible-responses.test.ts b/packages/llm/test/provider/openai-compatible-responses.test.ts new file mode 100644 index 0000000000..a43acb7683 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-responses.test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { configure } from "../../src/providers/openai-compatible-responses" +import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" +import { OpenAIResponses } from "../../src/protocols/openai-responses" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" + +describe("OpenAI-compatible Responses route", () => { + it.effect("reuses the OpenAI Responses protocol for a configured deployment", () => + Effect.gen(function* () { + expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body) + expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport) + + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + provider: "example", + }).model("example-model") + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: "You are concise.", + prompt: "Say hello.", + }), + ) + + expect(prepared.route).toBe("openai-compatible-responses") + expect(prepared.protocol).toBe("openai-responses") + expect(prepared.model).toMatchObject({ + id: "example-model", + provider: "example", + route: { + id: "openai-compatible-responses", + endpoint: { + baseURL: "https://responses.example.test/v1", + path: "/responses", + }, + }, + }) + expect(prepared.body).toEqual({ + model: "example-model", + input: [ + { role: "system", content: "You are concise." }, + { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, + ], + store: false, + stream: true, + }) + }), + ) +}) From 26e27c7a7fd1c26a6dfabb2f2df3377bb488a36f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Jul 2026 10:56:12 -0400 Subject: [PATCH 06/17] fix(tui): distinguish asynchronous UI states (#36759) --- .../tui/src/component/dialog-integration.tsx | 21 +++--- .../tui/src/component/dialog-move-session.tsx | 29 ++++++-- packages/tui/src/component/dialog-pair.tsx | 62 +++++++++-------- .../tui/src/component/dialog-session-list.tsx | 45 ++++++++++-- packages/tui/src/component/dialog-skill.tsx | 41 ++++++++--- .../tui/src/component/prompt/autocomplete.tsx | 68 ++++++++++--------- .../feature-plugins/system/diff-viewer.tsx | 38 +++++------ packages/tui/src/ui/dialog-select.tsx | 22 ++++-- .../tui/test/cli/tui/dialog-select.test.tsx | 2 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 13 +++- 10 files changed, 222 insertions(+), 119 deletions(-) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 74aa76040b..de95f8c14c 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected No integrations available } + noMatchView={ + + No integrations found + + } /> ) } @@ -183,8 +188,8 @@ function KeyMethod(props: { placeholder="API key" onConfirm={(key) => { if (!key) return - void client.api.integration - .connect.key({ + void client.api.integration.connect + .key({ integrationID: props.integration.id, location: location(data), key, @@ -222,8 +227,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void client.api.integration - .connect.oauth({ + void client.api.integration.connect + .oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -291,8 +296,8 @@ function OAuthAuto(props: { })) const poll = () => { - void client.api.integration - .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -357,8 +362,8 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void client.api.integration - .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void client.api.integration.attempt + .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index bc30045b40..3f75fd7f2a 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } +export type MoveSessionSelection = + | { type: "directory"; directory: string; subdirectory: boolean } + | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (showError()) return [] const data = directoryData() const current = currentRoot()?.directory - if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] + if (directories.loading && !data && !current) return [] const roots = [...(data ?? [])] if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) roots.sort((a, b) => { @@ -130,13 +132,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length return 0 }) - if (roots.length === 0) return [{ title: "No project directories found", value: undefined }] + if (roots.length === 0) return [] const subdirectories = sessionData.session .list() .filter( - (session) => - session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), + (session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath), ) .map((session) => session.location.directory) .filter((directory) => !roots.some((root) => root.directory === directory)) @@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { options={options()} emptyView={ showError() ? ( - + Could not load project directories {errorMessage(loadError())} + Close and reopen Move session to try again. - ) : undefined + ) : directories.loading || loadedProject.loading ? ( + + Loading project directories… + + ) : ( + + No project directories available + + ) + } + noMatchView={ + + No project directories found + } locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index d1b5b10787..8f77bff7a1 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { dialog.setCentered(true) const [server] = createResource(() => - client.api.server - .get() - .catch((error) => { - setLoadError(error) - return undefined - }), + client.api.server.get().catch((error) => { + setLoadError(error) + return undefined + }), ) const info = createMemo(() => { const current = server() @@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const value = info() if (!value) return return ( - + URLs @@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { {showPassword() ? value.password : "************"} - ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))} - > + ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}> Run `opencode service set hostname 0.0.0.0` to access the service remotely. @@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { esc - - {(error) => {errorMessage(error())}} - - Loading server information...}> - = 36} - fallback={ - Loading server information…}> + = 36} + fallback={ + + {content()} + + } > {content()} - - } - > - {content()} - + + + } + > + {(error) => ( + + + Could not load server information + + {errorMessage(error())} + Close and reopen Pair to try again. + + )} ) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 07830e4202..d9e6067b01 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -26,6 +26,7 @@ export function DialogSessionList() { const client = useClient() const local = useLocal() const toast = useToast() + const [filter, setFilter] = createSignal("") const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() const quickSwitch1 = useCommandShortcut("session.quick_switch.1") @@ -44,21 +45,38 @@ export function DialogSessionList() { directory: location.directory, workspace: location.workspaceID, }) - return { query, sessions: response.data } + return { query, sessions: response.data, error: undefined } } catch (error) { // A transient transport failure must degrade search, not crash the TUI // through the root ErrorBoundary when the errored resource is read. - toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) - return { query, sessions: [] as SessionInfo[] } + return { query, sessions: [] as SessionInfo[], error } } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const localSessions = createMemo(() => { + const query = filter().trim().toLowerCase() + const sessions = data.session.list() + if (!query) return sessions + return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) + }) const sessions = createMemo(() => { - const query = search() - if (!query) return data.session.list() + const query = filter() + const local = localSessions() + if (!query) return local + if (query !== search() || searchResults.loading) return local const result = searchResults() - return result?.query === query ? result.sessions : [] + if (result?.query !== query || result.error) return local + return result.sessions + }) + const searchState = createMemo(() => { + const query = filter() + if (!query) return { message: "No sessions available", error: false } + if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false } + const result = searchResults() + if (result?.query === query && result.error) + return { message: "Could not search sessions. Change the search to try again.", error: true } + return { message: "No sessions found", error: false } }) const quickSwitchHint = createMemo(() => { @@ -120,7 +138,20 @@ export function DialogSessionList() { options={options()} skipFilter={true} current={currentSessionID()} - onFilter={setSearch} + onFilter={(query) => { + setFilter(query) + setSearch(query) + }} + emptyView={ + + No sessions available + + } + noMatchView={ + + {searchState().message} + + } onMove={() => setToDelete(undefined)} onSelect={(option) => { route.navigate({ type: "session", sessionID: option.value }) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index d49d054c91..3826a5a3c5 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { createResource, createMemo, createSignal } from "solid-js" +import { createResource, createMemo, createSignal, Match, Switch } from "solid-js" import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" @@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) { - - Could not load skills - - {errorMessage(loadError())} - - ) : undefined + + No skills available + + } + > + + + + Could not load skills + + {errorMessage(loadError())} + Close and reopen Skills to try again. + + + + + Loading skills… + + + + } + noMatchView={ + + No skills found + } /> ) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 03e1b5fcdb..cd67084115 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -309,10 +309,10 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location() }), + () => ({ query: search(), location: location(), visible: store.visible }), async (input) => { - if (!store.visible || store.visible === "/") return [] - if (referenceMatch()) return [] + if (!input.visible || input.visible === "/") return { options: [], failed: false } + if (referenceMatch()) return { options: [], failed: false } const { lineRange, baseQuery } = extractLineRange(input.query ?? "") const result = await client.api.file @@ -324,34 +324,37 @@ export function Autocomplete(props: { workspace: input.location?.workspaceID ?? project.workspace.current(), }, }) - .catch(() => undefined) + .then( + (result) => result, + () => undefined, + ) + + if (!result) return { options: [], failed: true } const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). - if (result) { - const width = props.anchor().width - 4 - options.push( - ...result.data.map((item): AutocompleteOption => { - const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) - return { - display: Locale.truncateMiddle(filename, width), - value: filename, - isDirectory: item.type === "directory", - path: item.path, - onSelect: () => { - insertPart(filename, part) - }, - } - }), - ) - } + const width = props.anchor().width - 4 + options.push( + ...result.data.map((item): AutocompleteOption => { + const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) + return { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: item.type === "directory", + path: item.path, + onSelect: () => { + insertPart(filename, part) + }, + } + }), + ) - return options + return { options, failed: false } }, { - initialValue: [], + initialValue: { options: [], failed: false }, }, ) @@ -470,8 +473,8 @@ export function Autocomplete(props: { })) }) - const options = createMemo((prev: AutocompleteOption[] | undefined) => { - const filesValue = files() + const options = createMemo(() => { + const fileSearch = files() const referenceMatchValue = referenceMatch() const agentsValue = agents() const referenceAliasesValue = referenceAliases() @@ -484,7 +487,7 @@ export function Autocomplete(props: { // Files come from fff already fuzzy ranked and filtered // it shouldn't be additionally sorted by fuzzysort as it will loose the results - const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] + const fileOptions: AutocompleteOption[] = store.visible === "@" && !files.loading ? fileSearch.options : [] const nonFileOptions: AutocompleteOption[] = store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] @@ -492,10 +495,6 @@ export function Autocomplete(props: { return [...nonFileOptions, ...fileOptions] } - if (files.loading && prev && prev.length > 0) { - return prev - } - const fuzziedNonFiles = fuzzysort .go(removeLineRange(searchValue), nonFileOptions, { keys: [ @@ -715,6 +714,13 @@ export function Autocomplete(props: { let scroll: ScrollBoxRenderable const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const emptyMessage = createMemo(() => { + if (store.visible === "/") return "No matching commands" + if (files.loading) return "Searching…" + if (files().failed) return "Could not search files. Keep typing to try again." + return "No matching files, agents, or references" + }) + const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed) return ( - No matching items + {emptyMessage()} } > diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 650bd29474..f1ff072bdd 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -104,16 +104,14 @@ function DiffViewer(props: { api: TuiPluginApi }) { } }) const [diff] = createResource(diffInput, async (input) => { - const result = await client.api.vcs.diff( - { - location: input.directory ? { directory: input.directory } : undefined, - mode: input.mode, - context: VCS_DIFF_CONTEXT_LINES, - }, - ) + const result = await client.api.vcs.diff({ + location: input.directory ? { directory: input.directory } : undefined, + mode: input.mode, + context: VCS_DIFF_CONTEXT_LINES, + }) return normalizeDiffs(result.data ?? []) }) - const files = createMemo(() => diff() ?? []) + const files = createMemo(() => (diff.error ? [] : (diff() ?? []))) const [focus, setFocus] = createSignal("patches") const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true) const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) @@ -748,9 +746,11 @@ function DiffViewer(props: { api: TuiPluginApi }) { Diff {diffSourceLabel(mode())} - - {files().length} {files().length === 1 ? "file" : "files"} - + + + {files().length} {files().length === 1 ? "file" : "files"} + + @@ -758,19 +758,19 @@ function DiffViewer(props: { api: TuiPluginApi }) { - Loading diff... - - - - - - No diff! + Loading diff… - Failed to load diff + Could not load diff. Reopen the diff viewer to try again. + + + + + + No changes to show diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 1d225dffc0..a33a149bd5 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -26,6 +26,7 @@ export interface DialogSelectProps { placeholder?: string footer?: JSX.Element emptyView?: JSX.Element + noMatchView?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void @@ -615,11 +616,22 @@ export function DialogSelect(props: DialogSelectProps) { 0} fallback={ - props.emptyView ?? ( - - No results found - - ) + 0} + fallback={ + props.emptyView ?? ( + + No items available + + ) + } + > + {props.noMatchView ?? ( + + No results found + + )} + } > try { select.replaceOptions([]) - await select.app.waitForFrame((frame) => frame.includes("No results found")) + await select.app.waitForFrame((frame) => frame.includes("No items available")) select.app.mockInput.pressEnter() expect(select.selected).toEqual([]) diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index a4f2ec0ef6..e644258766 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -36,6 +36,16 @@ test("closing the diff viewer returns to the route it opened from", async () => } }) +test("shows an error instead of an empty diff when loading fails", async () => { + const viewer = await renderDiffViewer([], 20, undefined, true) + try { + await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff")) + expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") + } finally { + viewer.app.renderer.destroy() + } +}) + test("brackets navigate diff hunks", async () => { const viewer = await renderDiffViewer( [ @@ -102,7 +112,7 @@ test("brackets navigate diff hunks", async () => { } }) -async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) { +async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) { const commands = new Map< string, NonNullable[0]["commands"]>[number] @@ -113,6 +123,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: const config = createTuiResolvedConfig() const transport = createFetch((url) => { if (url.pathname !== "/api/vcs/diff") return + if (fail) return json({ message: "boom" }, { status: 500 }) vcsDiffInput = { location: { directory: url.searchParams.get("location[directory]") }, mode: url.searchParams.get("mode"), From 634386fe0f2249fae5bd4e69d064dfbc58a80e8c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:58:18 -0500 Subject: [PATCH 07/17] chore: merge dev into v2 (#36770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: opencode-agent[bot] Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Brendan Allan Co-authored-by: Kit Langton Co-authored-by: James Long Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Simon Klee Co-authored-by: Jay Co-authored-by: Jack Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode Co-authored-by: Aiden Cline Co-authored-by: James Long Co-authored-by: Dustin Deus Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Frank Co-authored-by: Aiden Cline Co-authored-by: Victor Navarro Co-authored-by: Dax Raad Co-authored-by: Dax Co-authored-by: Nabs Co-authored-by: Vladimir Glafirov Co-authored-by: AidenGeunGeun Co-authored-by: Mark --- .opencode/tool/github-triage.ts | 2 +- bun.lock | 130 ++-- nix/hashes.json | 8 +- package.json | 9 +- .../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 +- .../regression/session-request-docks.spec.ts | 62 ++ .../session-timeline-projection.spec.ts | 5 +- packages/app/package.json | 2 +- .../src/components/dialog-edit-project-v2.tsx | 156 +++++ .../src/components/dialog-edit-project.tsx | 173 ++---- packages/app/src/components/edit-project.ts | 119 ++++ .../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 | 89 ++- packages/app/src/components/prompt-input.tsx | 23 +- .../components/prompt-input/context-items.tsx | 16 +- .../prompt-input/image-attachments.css | 43 ++ .../prompt-input/image-attachments.tsx | 155 ++++- .../components/prompt-workspace-selector.tsx | 16 +- packages/app/src/components/session/index.ts | 1 + .../src/components/session/open-in-app-v2.tsx | 98 +++ .../src/components/session/open-in-app.tsx | 229 +++++++ .../session/session-sortable-tab-v2.tsx | 66 ++ .../status-popover-indicator.test.ts | 36 ++ .../components/status-popover-indicator.ts | 19 + .../app/src/components/status-popover.tsx | 68 +-- packages/app/src/context/layout.tsx | 16 +- 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/i18n/en.ts | 2 +- .../app/src/pages/home-session-open.test.ts | 29 +- packages/app/src/pages/home-session-open.ts | 3 + packages/app/src/pages/home.tsx | 1 + packages/app/src/pages/session.tsx | 43 +- packages/app/src/pages/session/file-tabs.tsx | 330 +++++++++- .../src/pages/session/session-side-panel.tsx | 568 +++++++++++++----- .../session/timeline/message-timeline.tsx | 6 + .../src/pages/session/timeline/projection.ts | 2 + .../app/src/pages/session/timeline/rows.ts | 6 +- .../src/pages/session/v2/review-panel-v2.tsx | 8 +- .../session/v2/session-file-browser-tab.tsx | 163 +++-- .../app/test-browser/solid-virtual.test.ts | 61 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 8 +- packages/desktop/package.json | 2 +- 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 + packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 8 +- packages/opencode/src/account/account.ts | 14 +- packages/opencode/src/plugin/openai/codex.ts | 3 + packages/opencode/src/provider/provider.ts | 33 +- packages/opencode/src/provider/transform.ts | 14 +- .../opencode/test/account/service.test.ts | 47 ++ packages/opencode/test/plugin/codex.test.ts | 33 +- .../opencode/test/provider/provider.test.ts | 22 +- .../opencode/test/provider/transform.test.ts | 73 +++ packages/opencode/test/session/llm.test.ts | 3 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- .../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 + .../line-comment-annotations-v2.tsx | 8 + .../src/v2/components/session-review-v2.css | 7 +- .../src/v2/components/session-review-v2.tsx | 5 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 3 +- .../stats/app/src/routes/[lab]/[model].tsx | 68 +-- packages/stats/app/src/routes/[lab]/index.tsx | 37 +- .../app/src/routes/breadcrumb-select.tsx | 71 +++ .../stats/app/src/routes/compare/index.tsx | 2 +- packages/stats/app/src/routes/index.css | 282 ++++----- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- .../.storybook/mocks/app/context/prompt.ts | 4 + packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/components/tabs.css | 101 ++++ .../ui/src/context/marked-code-span.test.ts | 13 + packages/ui/src/context/marked-code-span.ts | 17 + packages/ui/src/context/marked.tsx | 2 + .../ui/src/v2/components/file-tree-v2.css | 19 +- packages/ui/src/v2/components/icon.tsx | 8 + .../ui/src/v2/components/line-comment-v2.css | 53 ++ .../ui/src/v2/components/line-comment-v2.tsx | 153 ++++- .../src/v2/components/project-avatar-v2.tsx | 5 +- .../ui/src/v2/components/split-button-v2.css | 99 +++ .../ui/src/v2/components/split-button-v2.tsx | 48 ++ packages/web/package.json | 2 +- .../@tanstack%2Fsolid-virtual@3.13.28.patch | 45 -- patches/@tanstack%2Fvirtual-core@3.17.0.patch | 105 ---- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 108 ++++ sdks/vscode/package.json | 2 +- 116 files changed, 3797 insertions(+), 1049 deletions(-) create mode 100644 packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts create mode 100644 packages/app/src/components/dialog-edit-project-v2.tsx create mode 100644 packages/app/src/components/edit-project.ts create mode 100644 packages/app/src/components/prompt-input/image-attachments.css 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/app/src/components/status-popover-indicator.test.ts create mode 100644 packages/app/src/components/status-popover-indicator.ts 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 create mode 100644 packages/stats/app/src/routes/breadcrumb-select.tsx create mode 100644 packages/ui/src/context/marked-code-span.test.ts create mode 100644 packages/ui/src/context/marked-code-span.ts create mode 100644 packages/ui/src/v2/components/split-button-v2.css create mode 100644 packages/ui/src/v2/components/split-button-v2.tsx delete mode 100644 patches/@tanstack%2Fsolid-virtual@3.13.28.patch delete mode 100644 patches/@tanstack%2Fvirtual-core@3.17.0.patch create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.3.patch 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 diff --git a/bun.lock b/bun.lock index c0f7eb05a6..fd03c58e7d 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -157,7 +157,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -171,7 +171,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -207,7 +207,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -234,7 +234,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -256,7 +256,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -280,7 +280,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -300,7 +300,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.18", + "version": "1.17.20", "bin": { "opencode": "./bin/opencode", }, @@ -308,7 +308,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -317,7 +317,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -354,7 +354,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -396,7 +396,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -456,7 +456,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -470,7 +470,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "effect": "catalog:", }, @@ -482,7 +482,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -514,7 +514,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -530,7 +530,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@effect/platform-node-shared": "4.0.0-beta.83", }, @@ -562,7 +562,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -581,7 +581,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.18", + "version": "1.17.20", "bin": { "opencode": "./bin/opencode", }, @@ -592,7 +592,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -601,7 +601,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -656,7 +656,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -714,7 +714,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/client": "workspace:*", @@ -801,7 +801,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "cross-spawn": "catalog:", }, @@ -816,7 +816,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", @@ -833,7 +833,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -894,7 +894,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -907,9 +907,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -940,7 +941,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -959,7 +960,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -1000,7 +1001,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -1030,7 +1031,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1081,7 +1082,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1152,6 +1153,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", @@ -1159,10 +1161,8 @@ "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { @@ -1198,7 +1198,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1218,7 +1218,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1260,7 +1260,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1270,7 +1270,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -3122,7 +3122,7 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="], @@ -3136,7 +3136,7 @@ "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -4344,7 +4344,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.1", "", { "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-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4854,7 +4854,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -6340,7 +6340,11 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6352,9 +6356,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -6844,7 +6848,7 @@ "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6884,6 +6888,8 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -7230,6 +7236,10 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], @@ -7534,7 +7544,7 @@ "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -8226,6 +8236,10 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -8470,6 +8484,14 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -8782,6 +8804,10 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -9116,6 +9142,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], @@ -9302,6 +9330,10 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -9402,6 +9434,8 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 50208fdbbe..20e5882de0 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-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=", + "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=", + "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=", + "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw=" } } diff --git a/package.json b/package.json index cef269ea54..962f4ee3c1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -76,7 +76,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -161,10 +161,9 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } 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/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef42..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + async function mockServer( page: Page, requests: { diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 45304df365..1ebda43478 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -150,7 +150,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/package.json b/packages/app/package.json index 510e2015db..3a0eb6d426 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.17.20", "description": "", "type": "module", "exports": { 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/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..26cf49d411 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) => ( -
- )} + {(_, index) =>
} ) } @@ -126,12 +127,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 +162,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 +215,7 @@ export default function FileTreeV2(props: {
@@ -209,13 +249,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 +274,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/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e8722..284e04447f 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, @@ -632,7 +633,9 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - savedCursor = currentCursor() + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -1626,7 +1629,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 +1639,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} />
)} - {props.state.modelName} + {props.state.modelName} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index 6b154ee1e5..1f03f199ab 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -1,6 +1,8 @@ import { Component, For, Show } from "solid-js" +import { Dynamic } from "solid-js/web" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import type { ContextItem } from "@/context/prompt" @@ -12,6 +14,7 @@ type ContextItemsProps = { active: (item: PromptContextItem) => 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/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()} +
+
)} 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..e26ff2e0ea --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -0,0 +1,98 @@ +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..cd363d00c4 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,229 @@ +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/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts new file mode 100644 index 0000000000..e3c62d2a95 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" + +describe("serverStatusDotClass", () => { + test("uses the success token while the server and services are healthy", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") + }) + + test("uses the warning token for non-blocking issues while the server is online", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") + }) + + test("uses the critical token only after the server connection drops", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base") + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base") + }) + + test("stays neutral before status is ready", () => { + expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base") + expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base") + }) +}) + +describe("hasNonBlockingServiceIssue", () => { + test("detects MCP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false) + }) + + test("detects LSP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) + }) +}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts new file mode 100644 index 0000000000..efb7473753 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.ts @@ -0,0 +1,19 @@ +import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" + +export function hasNonBlockingServiceIssue(input: { + mcp: Array + lsp: Array +}) { + return ( + input.mcp.some((status) => status !== "connected" && status !== "disabled") || + input.lsp.some((status) => status === "error") + ) +} + +export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) { + if (input.serverHealth === false) return "bg-icon-critical-base" + if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" + if (input.issue) return "bg-icon-warning-base" + if (input.serverHealth === true) return "bg-icon-success-base" + return "bg-border-weak-base" +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c75dd5b655..2aed45702a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" +import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -19,16 +20,14 @@ export function StatusPopover() { const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync().data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const serverHealthy = () => global.servers.health[server.key]?.healthy === true - const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) + const serverHealth = () => global.servers.health[server.key]?.healthy + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) return (
} @@ -87,21 +84,18 @@ function DirectoryStatusPopover() { const sync = useSync() const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy - const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync().data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const healthy = createMemo(() => serverHealth() === true && !mcpIssue()) + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) const state = createMemo(() => ({ shown: shown(), ready: ready(), - healthy: healthy(), serverHealth: serverHealth(), - issue: mcpIssue(), + issue: issue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -123,8 +117,8 @@ function ServerStatusPopover() { const state = createMemo(() => ({ shown: shown(), ready: serverHealth() !== undefined, - healthy: serverHealth() === true, serverHealth: serverHealth(), + issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -140,9 +134,8 @@ function ServerStatusPopover() { type StatusPopoverState = { shown: boolean ready: boolean - healthy: boolean serverHealth: boolean | undefined - issue?: "critical" | "warning" + issue: boolean label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) { } function StatusPopoverView(props: { state: StatusPopoverState }) { - const statusDotClass = () => ({ - "absolute rounded-full": true, - "bg-icon-success-base": props.state.ready && props.state.healthy, - "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning", - "bg-icon-critical-base": - props.state.serverHealth === false || - (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"), - "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready, - }) - const popoverProps = { class: "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", @@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 8a26d91c46..8e09539fbe 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/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/i18n/en.ts b/packages/app/src/i18n/en.ts index 343923af76..6b54e69822 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/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts index a71d58ff07..74b9426654 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..74c9b05d29 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { // 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, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9a0518fb91..2121ecf3ad 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 { @@ -57,6 +58,8 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" +import { setCursorPosition } from "@/components/prompt-input/editor-dom" +import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -79,6 +82,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" @@ -1015,7 +1019,10 @@ export default function Page() { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (composer.blocked() || isChildSession()) return - inputRef?.focus() + const input = inputRef + if (!input) return + input.focus() + setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) } } @@ -1867,7 +1874,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 @@ -2232,6 +2262,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..b54fbc4e4c 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,30 @@ 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 +208,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 +500,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 b85e7ab13d..7c84ca7078 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 { FileDiffInfo, 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" @@ -47,6 +65,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 @@ -60,7 +79,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 @@ -92,11 +113,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) @@ -153,6 +172,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 @@ -170,10 +190,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) @@ -196,10 +214,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) @@ -285,72 +319,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} @@ -386,106 +631,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)} + /> +
+
+ + +
@@ -513,10 +756,19 @@ 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/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 168bdf2f69..7438d6febd 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/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index cf9367d063..3f24b702c7 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" @@ -65,6 +64,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. @@ -112,9 +113,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. @@ -126,7 +124,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/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 716fa7fa9e..9327eacf42 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -1,8 +1,32 @@ import { expect, test } from "bun:test" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual" import { createRoot, createSignal } from "solid-js" import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" +test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => { + const writes: { offset: number; adjustments?: number }[] = [] + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + initialOffset: 50, + initialRect: { width: 400, height: 200 }, + anchorTo: "end", + scrollEndThreshold: 1, + getScrollElement: () => null, + scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }), + observeElementRect: () => {}, + observeElementOffset: () => {}, + }) + + virtualizer.getTotalSize() + virtualizer.resizeItem(4, 120) + expect(writes).toEqual([{ offset: 50, adjustments: 70 }]) + writes.length = 0 + + virtualizer.resizeItem(4, 200) + expect(writes).toEqual([{ offset: 120, adjustments: 80 }]) +}) + test("reactive count updates preserve measured row sizes", () => { createRoot((dispose) => { const [count, setCount] = createSignal(2) @@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => { }) }) -test("logical scroll offset includes pending measurement adjustments", () => { - createRoot((dispose) => { - const virtualizer = createVirtualizer({ - count: 2, - getScrollElement: () => null, - estimateSize: () => 60, - initialOffset: 100, - initialRect: { width: 800, height: 60 }, - }) - - virtualizer.getTotalSize() - virtualizer.resizeItem(0, 100) - - expect(virtualizer.scrollOffset).toBe(100) - expect(virtualizer.getLogicalScrollOffset()).toBe(140) - dispose() +test("clamps oversized offsets with scroll margin and padding changes", () => { + const options = (paddingEnd: number) => ({ + count: 20, + estimateSize: () => 60, + initialOffset: Number.MAX_SAFE_INTEGER, + initialRect: { width: 800, height: 600 }, + scrollMargin: 64, + paddingEnd, + overscan: 1, + getScrollElement: () => null, + scrollToFn: () => {}, + observeElementRect: () => {}, + observeElementOffset: () => {}, }) + const virtualizer = new Virtualizer(options(64)) + + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + + virtualizer.setOptions(options(600)) + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19]) }) test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 263daa6a53..43072205a2 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.17.20", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 35d31f9342..52fc4a17e4 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ffc7a756d2..9820650fae 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 47ae1efd7a..7497160977 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.18", + "version": "1.17.20", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d0f16d771f..fd52148535 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 159a59722b..6bd1d8a7d8 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 4a3b6b01cd..374eb40f4d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -64,7 +64,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -73,7 +73,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -110,7 +110,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ece963cbc2..8436062a5e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", 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/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6aebc6f41d..d5d3c45c3b 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 117aab177f..9912ecda1a 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ea959c4e4f..57b73a3efb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 282076fa65..5b576df63f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.18", + "version": "1.17.20", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index dbb0cafca9..8306dc8ae3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 8e1c674494..3ca36b1310 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f1695e9d89..0f4c85d246 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "opencode", "type": "module", "license": "MIT", @@ -58,7 +58,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -67,7 +67,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -122,7 +122,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", 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 { + if (model.options.reasoningMode === "pro") return false 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 }) @@ -416,6 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug const requestInit = { ...init, + body: init?.body, headers, } if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c6051c8996..373e6af579 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1266,6 +1266,14 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data. const id = `${model.id}-${mode}` const base = fromModelsDevModel(provider, model) + const providerOptions = ( + opts as { + readonly provider?: { + readonly body?: Record + readonly headers?: Record + } + } + ).provider models[id] = { ...base, id: ModelV2.ID.make(id), @@ -1273,18 +1281,8 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data. cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, - // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. - options: opts.provider?.body - ? Object.fromEntries( - // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. - Object.entries(opts.provider.body).map(([k, v]) => [ - k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), - v, - ]), - ) - : base.options, - // @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data. - headers: opts.provider?.headers ?? base.headers, + options: modeOptions(base, providerOptions?.body), + headers: providerOptions?.headers ?? base.headers, } } } @@ -1298,6 +1296,17 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } +function modeOptions(model: Model, body: Record | undefined) { + if (!body) return model.options + const options = Object.fromEntries( + Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]), + ) + const reasoning = body.reasoning + if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options + const { reasoning: _, ...rest } = options + return { ...rest, reasoningMode: reasoning.mode } +} + function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) { const available = provider ? Object.keys(provider.models).filter((id) => { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 9f2b343163..a996ab3669 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -761,7 +761,7 @@ export function variants(model: Provider.Model): Record [ @@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record }), ) +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") diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 0d789bb42b..1381c4ee8a 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -149,16 +149,32 @@ describe("plugin.codex", () => { await enabled.dispose?.() }) - test("uses Codex context limits for OAuth GPT models", async () => { + test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => { const hooks = await CodexAuthPlugin({} as never) const limit = { context: 1_050_000, input: 922_000, output: 128_000 } const provider = { - models: Object.fromEntries( - ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [ - id, - { id, api: { id }, limit, cost: {} }, - ]), - ), + models: { + ...Object.fromEntries( + ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [ + id, + { id, api: { id }, limit, cost: {}, options: {} }, + ]), + ), + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + api: { id: "gpt-5.4" }, + limit, + cost: {}, + options: { reasoningMode: "pro" }, + }, + "gpt-5.6-sol-high": { + id: "gpt-5.6-sol-high", + api: { id: "gpt-5.6-sol" }, + limit, + cost: {}, + options: { reasoningEffort: "high" }, + }, + }, } const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) @@ -168,6 +184,9 @@ describe("plugin.codex", () => { expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) + expect(models["gpt-5.4-pro"]).toBeUndefined() + expect(models["gpt-5.7-pro"]).toBeDefined() + expect(models["gpt-5.6-sol-high"]).toBeDefined() expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe( provider.models as never, ) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 78d8930886..964c7d5dfd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1339,16 +1339,17 @@ it.instance( }, ) -test("mode cost preserves over-200k pricing from base model", () => { +test("mode options and cost are derived from the base model", () => { const provider = { id: "openai", name: "OpenAI", env: [], + npm: "@ai-sdk/openai", api: "https://api.openai.com/v1", models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", family: "gpt", release_date: "2026-03-05", attachment: true, @@ -1384,18 +1385,29 @@ test("mode cost preserves over-200k pricing from base model", () => { }, }, }, + pro: { + provider: { + body: { + reasoning: { mode: "pro" }, + service_tier: "priority", + }, + }, + }, }, }, }, }, } as unknown as ModelsDevProvider - const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"] expect(model.cost.input).toEqual(5) expect(model.cost.output).toEqual(30) expect(model.cost.cache.read).toEqual(0.5) expect(model.cost.cache.write).toEqual(0) expect(model.options["serviceTier"]).toEqual("priority") + const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"] + expect(pro.api.id).toEqual("gpt-5.6-sol") + expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" }) expect(model.cost.experimentalOver200K).toEqual({ input: 5, output: 22.5, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e69dab70f6..2eb31ac86b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -155,6 +155,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, @@ -3372,6 +3409,42 @@ describe("ProviderTransform.variants", () => { }) 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", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 15e2251b75..33c2957372 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1095,7 +1095,7 @@ describe("session.llm.stream", () => { const agent = { name: "test", mode: "primary", - options: {}, + options: { reasoningMode: "pro" }, permission: [{ permission: "*", pattern: "*", action: "allow" }], temperature: 0.2, } satisfies Agent.Info @@ -1126,6 +1126,7 @@ describe("session.llm.stream", () => { expect(body.model).toBe(resolved.api.id) expect(body.stream).toBe(true) expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high") + expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro") const maxTokens = body.max_output_tokens as number | undefined expect(maxTokens).toBe(undefined) // match codex cli behavior diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 69a6a3417b..1666138155 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 81e184968f..6c987d51c0 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 17507ea5b4..e695ea8f87 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index cf3be61f36..0b756b68b6 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", 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 b50f0902d0..d88961aae0 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"] { @@ -1301,6 +1313,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 b16b0344f3..717e304678 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -45,6 +45,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" @@ -58,7 +60,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" @@ -165,6 +167,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean useV2Actions?: boolean + comments?: UserMessageComment[] } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -172,6 +175,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 { @@ -937,6 +950,7 @@ export function Message(props: MessageProps) { parts={props.parts} actions={props.actions} useV2Actions={props.useV2Actions} + comments={props.comments} /> )} @@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: { parts: PartType[] actions?: UserActions useV2Actions?: boolean + comments?: UserMessageComment[] }) { const data = useData() const dialog = useDialog() @@ -1183,6 +1198,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[]) ?? []) @@ -1239,35 +1256,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/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/slack/package.json b/packages/slack/package.json index 11bb354f0d..d443317ce0 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index f1def44cec..6dc7bc45fb 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", @@ -13,6 +13,7 @@ }, "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 7d14ec3d01..ea7502976a 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 { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -294,61 +295,46 @@ function ModelHero(props: { 0} fallback={ - + {props.labName} } > -
- - {props.labName} - - -
- - {(lab) => ( - - {lab.name} - - )} - -
-
+ ({ + href: language.route(`${import.meta.env.BASE_URL}${lab.id}`), + label: lab.name, + value: lab.id, + }))} + value={providerSlug(labId())} + variant="model" + />
/ 0} fallback={ - + {modelName()} } > -
- - {modelName()} - - -
- - {(model) => ( - - {model.name} - - )} - -
-
+ ({ + href: language.route(`${import.meta.env.BASE_URL}${model.id}`), + label: model.name, + value: model.id, + }))} + value={props.catalog?.id ?? ""} + variant="model" + />
@@ -432,7 +418,7 @@ function ModelHeroSparkline(props: { data: StatsModelData }) { function ChevronDownIcon() { return ( ) } diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 01b7a4fc2f..1be2a82ca9 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -29,6 +29,7 @@ import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards" +import { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -205,6 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) { function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) { const language = useLanguage() const labs = () => props.labs ?? [] + const current = () => labs().find((lab) => lab.name === props.label) return ( ) diff --git a/packages/stats/app/src/routes/breadcrumb-select.tsx b/packages/stats/app/src/routes/breadcrumb-select.tsx new file mode 100644 index 0000000000..6057ea2710 --- /dev/null +++ b/packages/stats/app/src/routes/breadcrumb-select.tsx @@ -0,0 +1,71 @@ +import { Select } from "@kobalte/core/select" +import { createMemo } from "solid-js" + +export type BreadcrumbSelectOption = { + href: string + label: string + value: string +} + +export function BreadcrumbSelect(props: { + ariaLabel: string + current?: boolean + label: string + options: BreadcrumbSelectOption[] + value: string + variant: "lab" | "model" +}) { + const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null) + + return ( + + data-component="stats-breadcrumb-select" + data-variant={props.variant} + options={props.options} + optionValue="value" + optionTextValue="label" + value={selected()} + placeholder={props.label} + disallowEmptySelection + closeOnSelection + shouldFocusWrap + placement="bottom" + gutter={8} + sameWidth={false} + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + onChange={(option) => { + if (!option || option.value === props.value) return + window.location.assign(option.href) + }} + > + + data-slot="stats-breadcrumb-select-value"> + {(state) => {state.selectedOption()?.label ?? props.label}} + + + + + + + + + + + ) +} diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx index 03b33c4129..b7f88d8d7e 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -134,7 +134,7 @@ export default function ModelCompareIndex() { Data / - + Compare diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index c37b665ca6..269a0d9ef3 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -2888,7 +2888,7 @@ padding: 0 8px; overflow: hidden; background: var(--stats-layer-2); - color: var(--stats-faint); + color: var(--stats-hero-muted); font-size: 13px; font-weight: 500; line-height: 1.1; @@ -2897,21 +2897,7 @@ } [data-page="stats"] a[data-slot="lab-hero-crumb"] { - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="lab-hero-menu"] { - position: relative; - display: inline-block; -} - -[data-page="stats"] [data-component="lab-hero-menu"] summary { - cursor: pointer; - list-style: none; -} - -[data-page="stats"] [data-component="lab-hero-menu"] summary::-webkit-details-marker { - display: none; + color: var(--stats-hero-muted); } [data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] { @@ -2920,9 +2906,7 @@ } [data-page="stats"] a[data-slot="lab-hero-crumb"]:hover, -[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible, -[data-page="stats"] summary[data-slot="lab-hero-crumb"]:hover, -[data-page="stats"] summary[data-slot="lab-hero-crumb"]:focus-visible { +[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible { color: var(--stats-text); outline: none; text-decoration: none; @@ -2941,57 +2925,6 @@ color: currentColor; } -[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg { - transform: rotate(180deg); -} - -[data-page="stats"] [data-slot="lab-hero-options"] { - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 5; - display: grid; - width: max-content; - min-width: 180px; - max-width: min(280px, calc(100vw - 48px)); - max-height: min(360px, calc(100vh - 160px)); - padding: 6px; - overflow: auto; - border: 1px solid var(--stats-line); - background: var(--stats-bg); - box-shadow: 0 12px 32px #0000001a; - transform: translateX(-50%); -} - -[data-page="stats"] [data-slot="lab-hero-option"] { - display: flex; - align-items: center; - min-width: 0; - height: 30px; - padding: 0 8px; - overflow: hidden; - color: var(--stats-muted); - font-size: 13px; - font-weight: 500; - line-height: 1.2; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-page="stats"] [data-slot="lab-hero-option"]:hover, -[data-page="stats"] [data-slot="lab-hero-option"]:focus-visible { - background: var(--stats-layer-2); - color: var(--stats-text); - outline: none; - text-decoration: none; -} - -[data-page="stats"] [data-slot="lab-hero-option"][data-current="true"] { - background: var(--stats-layer-2); - color: var(--stats-text); -} - [data-page="stats"] [data-slot="lab-hero-separator"] { display: inline-flex; align-items: center; @@ -3113,29 +3046,147 @@ white-space: nowrap; } -[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] { +[data-page="stats"] a[data-slot="model-hero-crumb"] { + color: var(--stats-hero-muted); +} + +[data-page="stats"] [data-slot="model-hero-crumb"][data-menu="true"] { padding-right: 4px; } -[data-page="stats"] [data-component="model-hero-menu"] { - position: relative; - display: inline-block; +[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] { + color: var(--stats-text); +} + +[data-page="stats"] [data-component="stats-breadcrumb-select"] { + display: inline-flex; min-width: 0; } -[data-page="stats"] [data-component="model-hero-menu"] summary { +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + flex: 0 1 auto; + max-width: min(100%, 280px); + height: 24px; + box-sizing: border-box; + margin: 0; + padding: 0 4px 0 8px; + overflow: hidden; + border: 0; + border-radius: 0; + appearance: none; + background: var(--stats-layer-2); + color: var(--stats-hero-muted); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1.1; + text-align: center; + white-space: nowrap; cursor: pointer; - list-style: none; } -[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker { - display: none; +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"], +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover, +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible { + color: var(--stats-text); + outline: 1px solid var(--stats-text); + outline-offset: 2px; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] { + min-width: 0; + overflow: hidden; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] { + display: inline-flex; + flex: 0 0 auto; + color: var(--stats-faint); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] svg { + width: 16px; + height: 16px; + color: currentColor; +} + +[data-page="stats"] + [data-slot="stats-breadcrumb-select-trigger"][data-expanded] + [data-slot="stats-breadcrumb-select-icon"] { + transform: rotate(180deg); +} + +[data-page="stats"] [data-popper-positioner]:has([data-component="stats-breadcrumb-select-content"]) { + z-index: 20; +} + +[data-page="stats"] [data-component="stats-breadcrumb-select-content"] { + z-index: 20; + width: max-content; + min-width: 180px; + max-width: min(360px, calc(100vw - 48px)); + overflow: hidden; + border: 1px solid var(--stats-line); + background: var(--stats-bg); + box-shadow: 0 12px 32px #0000001a; +} + +[data-page="stats"] [data-component="stats-breadcrumb-select-content"][data-variant="lab"] { + max-width: min(280px, calc(100vw - 48px)); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-listbox"] { + display: grid; + max-height: min(360px, calc(100vh - 160px)); + box-sizing: border-box; + margin: 0; + padding: 6px; + overflow: auto; + list-style: none; + outline: none; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"] { + display: flex; + align-items: center; + min-width: 0; + height: 30px; + box-sizing: border-box; + padding: 0 8px; + overflow: hidden; + color: var(--stats-muted); + font-size: 13px; + font-weight: 500; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + outline: none; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-highlighted], +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-selected] { + background: var(--stats-layer-2); + color: var(--stats-text); } [data-page="stats"] a[data-slot="model-hero-crumb"]:hover, -[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible, -[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover, -[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible { +[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible { color: var(--stats-text); outline: none; text-decoration: none; @@ -3151,58 +3202,7 @@ flex: 0 0 auto; width: 16px; height: 16px; - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg { - transform: rotate(180deg); -} - -[data-page="stats"] [data-slot="model-hero-options"] { - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 5; - display: grid; - width: max-content; - min-width: 180px; - max-width: min(360px, calc(100vw - 48px)); - max-height: min(360px, calc(100vh - 160px)); - padding: 6px; - overflow: auto; - border: 1px solid var(--stats-line); - background: var(--stats-bg); - box-shadow: 0 12px 32px #0000001a; - transform: translateX(-50%); -} - -[data-page="stats"] [data-slot="model-hero-option"] { - display: flex; - align-items: center; - min-width: 0; - height: 30px; - padding: 0 8px; - overflow: hidden; - color: var(--stats-muted); - font-size: 13px; - font-weight: 500; - line-height: 1.2; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-page="stats"] [data-slot="model-hero-option"]:hover, -[data-page="stats"] [data-slot="model-hero-option"]:focus-visible { - background: var(--stats-layer-2); - color: var(--stats-text); - outline: none; - text-decoration: none; -} - -[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] { - background: var(--stats-layer-2); - color: var(--stats-text); + color: currentColor; } [data-page="stats"] [data-slot="model-hero-separator"] { @@ -5533,6 +5533,14 @@ white-space: nowrap; } +[data-page="stats"] a[data-slot="compare-home-crumb"] { + color: var(--stats-hero-muted); +} + +[data-page="stats"] [data-component="compare-home-breadcrumb"] [data-slot="compare-home-crumb"][data-current="true"] { + color: var(--stats-text); +} + [data-page="stats"] a[data-slot="compare-home-crumb"]:hover, [data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible { color: var(--stats-text); @@ -7876,6 +7884,10 @@ max-width: min(100%, 240px); } + [data-page="stats"] [data-component="model-hero-breadcrumb"] [data-slot="stats-breadcrumb-select-trigger"] { + max-width: min(100%, 240px); + } + [data-page="stats"] [data-slot="model-hero-title-row"] { flex-wrap: wrap; gap: 16px; @@ -7967,10 +7979,6 @@ min-width: 48px; } - [data-page="stats"] [data-slot="lab-hero-options"] { - max-width: calc(100vw - 48px); - } - [data-page="stats"] [data-component="lab-related-list"] { grid-auto-columns: minmax(320px, 392px); grid-auto-flow: column; diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6733366530..a907acb570 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a4d44b874d..e40dc1275a 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", 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/tui/package.json b/packages/tui/package.json index fe3cdeb69c..162b3ddb94 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 26ffadbcf6..9723660afa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "repository": { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cd26b0cd50..0978ec5ad2 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -698,3 +698,104 @@ 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/context/marked-code-span.test.ts b/packages/ui/src/context/marked-code-span.test.ts new file mode 100644 index 0000000000..ba67b2f678 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test" +import { Marked } from "marked" +import { markedCodeSpanBoundary } from "./marked-code-span" + +test("preserves code spans adjacent to tildes", async () => { + const marked = new Marked(markedCodeSpanBoundary) + + expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe( + "

~0.1576 to measurement-window-only 0.00092

\n", + ) + expect(await marked.parse("`before`~`after`")).toBe("

before~after

\n") + expect(await marked.parse("~~`deleted code`~~")).toBe("

deleted code

\n") +}) diff --git a/packages/ui/src/context/marked-code-span.ts b/packages/ui/src/context/marked-code-span.ts new file mode 100644 index 0000000000..895935c311 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.ts @@ -0,0 +1,17 @@ +import type { MarkedExtension } from "marked" + +// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released. +export const markedCodeSpanBoundary = { + tokenizer: { + inlineText(src) { + const match = /^(`+(?=~)|~+(?=`))/.exec(src) + if (!match) return false + return { + type: "text", + raw: match[0], + text: match[0], + escaped: this.lexer.state.inRawBlock, + } + }, + }, +} satisfies MarkedExtension diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index a4f264ac67..cead61ba60 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -3,6 +3,7 @@ import markedShiki from "marked-shiki" import katex from "katex" import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" +import { markedCodeSpanBoundary } from "./marked-code-span" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" export const OpenCodeTheme = { @@ -521,6 +522,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( name: "Marked", init: (props: { nativeParser?: NativeMarkdownParser }) => { const jsParser = marked.use( + markedCodeSpanBoundary, { renderer: { link({ href, title, text }) { 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/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b0b70bf37f..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: ``, @@ -129,6 +133,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/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..ae0c000a80 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,39 @@ 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 +258,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/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) => }
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 ( +