diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a76c0a1f0..c69de1d93b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode diff --git a/CONTEXT.md b/CONTEXT.md index 4b5fabd93c4..97919b63f66 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,6 +60,21 @@ A temporary file created under OpenCode's shared tool-output directory to retain **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. +**OpenCode Client**: +The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -108,6 +123,51 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. +- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol. +- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client. +- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. +- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. @@ -124,6 +184,23 @@ The host-supplied environment overlay applied by the server when creating a PTY, - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## Client contract architecture + +Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server. + +Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases. + +Before stabilizing the client API: + +- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API. +- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. +- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. +- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. +- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests. +- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. +- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/bun.lock b/bun.lock index 580e4f9625e..78f9fc99bc7 100644 --- a/bun.lock +++ b/bun.lock @@ -110,6 +110,29 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "dependencies": { + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.10", @@ -479,6 +502,18 @@ "effect": "4.0.0-beta.83", }, }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "1.17.10", @@ -690,6 +725,20 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", "version": "1.17.10", @@ -1847,6 +1896,8 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1873,6 +1924,8 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], @@ -1885,6 +1938,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], diff --git a/nix/hashes.json b/nix/hashes.json index 35ecfaa04b1..b0b7856a039 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=", - "aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=", - "aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=", - "x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M=" + "x86_64-linux": "sha256-4RYkrGAbsrUw/n0ecPJpntSZYuV6GsmMMjK9R6MbMxU=", + "aarch64-linux": "sha256-kwSkouFxbEYzYAsr9gaVUQrT7YfbcoKb4kB9dhNtaFM=", + "aarch64-darwin": "sha256-mukRph5X1noBRhd5+0Ct7ZshTYxooGcoY+tZEXzfSvo=", + "x86_64-darwin": "sha256-CE8JgBAfZQmUnunPPw4XPPw3bkso1OALmNgmyGgJr1k=" } } diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts new file mode 100644 index 00000000000..031440f1660 --- /dev/null +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -0,0 +1,135 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("closing the active server's last tab opens the remaining server tab", async ({ page }) => { + const requests: string[] = [] + await mockServers(page, requests) + await page.addInitScript( + ({ serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify([ + { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await tabA.locator('[data-slot="tab-close"] button').click() + + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) + expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) + expect( + requests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory + }), + ).toBe(true) +}) + +test("legacy session routes preserve an existing tab's server", async ({ page }) => { + await mockServers(page, []) + await page.addInitScript( + ({ serverB, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), + ) + }, + { serverB, sessionB: sessionB.id }, + ) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`) + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page, requests: string[]) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + requests.push(url.toString()) + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/package.json b/packages/app/package.json index 50960b1e4d1..2fcba573d8b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,9 +17,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "bun run test:unit && bun run test:virtualizer", + "test": "bun run test:unit && bun run test:browser", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", - "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts ./test-browser/motion-spring.test.ts", + "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 181f7c1594d..dea5ee62ba6 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -51,7 +51,13 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route" +import { + legacySessionHref, + legacySessionServer, + requireServerKey, + selectSessionLineage, + sessionHref, +} from "./utils/session-route" import Session from "@/pages/session" import { NewHome, LegacyHome } from "@/pages/home" @@ -67,7 +73,15 @@ const SessionRoute = () => { const tabs = useTabs() if (params.id && settings.general.newLayoutDesigns()) { - return + const sessionID = params.id + return ( + + {(_) => { + const persisted = tabs.store.filter((item) => item.type === "session") + return + }} + + ) } // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) @@ -95,7 +109,7 @@ const TargetSessionRoute = () => { }) return ( - + @@ -119,7 +133,7 @@ function ResolvedTargetSessionRoute() { }, ({ id, sync }) => sync.session.lineage.resolve(id), ) - const current = createMemo(() => cached() ?? resolved()) + const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) const directory = createMemo(() => current()?.session.directory) const targetDirectory = () => directory()! @@ -134,7 +148,7 @@ function ResolvedTargetSessionRoute() { return ( params.id}> - }> + }> - {props.children} + {props.children} @@ -323,7 +337,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - {props.children} + {props.children} ) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 76da7d5b429..0fb89d29f92 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -export function DialogConnectProvider(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() - const providers = useProviders() + const providers = useProviders(props.directory) const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index f2b0432dde3..647e5002a29 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" +import { type Accessor, batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + directory?: Accessor } export function DialogCustomProvider(props: Props) { @@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) { dialog.close() return } - dialog.show(() => ) + dialog.show(() => ) } const addModel = () => { diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad9..a0821ef1a36 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "./dialog-select-provider" +import { decode64 } from "@/utils/base64" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() + const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81b..bcb4a2fbcbf 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,24 +10,27 @@ import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" type ModelState = ReturnType["model"] export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { - const model = props.model ?? useLocal().model + const local = useLocal() + const model = props.model ?? local.model const dialog = useDialog() - const providers = useProviders() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) const language = useLanguage() const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5b..e16d9a66571 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const local = useLocal() + const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const language = useLanguage() @@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() + const local = useLocal() + const directory = () => decode64(local.slug()) const provider = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 89310286daf..ff0ab5d2bf0 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -1,4 +1,4 @@ -import { Component, Show } from "solid-js" +import { type Accessor, Component, Show } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" @@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" -export const DialogSelectProvider: Component = () => { +export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") @@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => { onSelect={(x) => { if (!x) return if (x.id === CUSTOM_ID) { - dialog.show(() => ) + dialog.show(() => ) return } - dialog.show(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 98e39313727..302bf35c353 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" +import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { pathKey } from "@/utils/path-key" @@ -346,25 +347,10 @@ export const PromptInput: Component = (props) => { prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) const [picker, setPicker] = createStore({ projectOpen: false, projectSearch: "", diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 3087c774b76..82b2d906a89 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) { }) } +type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> +type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } + +type PromptAttachmentsCoreInput = { + capture: () => PromptTarget + editor: () => HTMLDivElement | undefined + focusEditor?: () => void + addPart?: (part: ContentPart) => boolean + warn?: () => void + readClipboardImage?: () => Promise + getPathForFile?: (file: File) => string +} + type PromptAttachmentsInput = { prompt: ReturnType editor: () => HTMLDivElement | undefined @@ -36,27 +49,22 @@ type PromptAttachmentsInput = { getPathForFile?: (file: File) => string } -export function createPromptAttachments(input: PromptAttachmentsInput) { - const prompt = input.prompt - const language = useLanguage() - - const warn = () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) +export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { + const capture = (): AttachmentTarget | undefined => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } } - const add = async (file: File, toast = true) => { + const add = async (file: File, toast = true, target = capture()) => { + if (!target) return false const mime = await attachmentMime(file) if (!mime) { - if (toast) warn() + if (toast) input.warn?.() return false } - const editor = input.editor() - if (!editor) return false - const url = await dataUrl(file, mime) if (!url) return false @@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { mime, dataUrl: url, } - const cursor = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursor) + target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true } const addAttachment = (file: File) => add(file) - const addAttachments = async (files: File[], toast = true) => { + const addAttachments = async (files: File[], toast = true, target = capture()) => { let found = false for (const file of files) { - const ok = await add(file, false) + const ok = await add(file, false, target) if (ok) found = true } - if (!found && files.length > 0 && toast) warn() + if (!found && files.length > 0 && toast) input.warn?.() return found } + const addClipboardAttachment = async (pending: Promise, target = capture()) => { + const file = await pending + if (!file) return false + return add(file, true, target) + } + const removeAttachment = (id: string) => { - const current = prompt.current() + const target = input.capture() + const current = target.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) + target.set(next, target.cursor()) } const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return + const target = capture() + if (!target) return event.preventDefault() event.stopPropagation() @@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { }) if (files.length > 0) { - await addAttachments(files) + await addAttachments(files, true, target) return } @@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file) { - await addAttachment(file) - return - } + if (await addClipboardAttachment(input.readClipboardImage(), target)) return } if (!plainText) return @@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const text = normalizePaste(plainText) const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor?.() + return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false } if (pasteMode(text) === "manual") { @@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { put() } + return { + addAttachment, + addAttachments, + addClipboardAttachment, + removeAttachment, + handlePaste, + } +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const language = useLanguage() + const attachments = createPromptAttachmentsCore({ + ...input, + capture: input.prompt.capture, + warn: () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + }, + }) + const handleGlobalDragOver = (event: DragEvent) => { if (input.isDialogActive()) return @@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return - await addAttachments(Array.from(dropped)) + await attachments.addAttachments(Array.from(dropped)) } onMount(() => { @@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { makeEventListener(document, "drop", handleGlobalDrop) }) - return { - addAttachment, - addAttachments, - removeAttachment, - handlePaste, - } + return attachments } diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts new file mode 100644 index 00000000000..ebc4c507151 --- /dev/null +++ b/packages/app/src/components/prompt-input/submission-state.ts @@ -0,0 +1,31 @@ +import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt" + +type PromptTarget = ReturnType["capture"]> + +export function createPromptSubmissionState(input: { + target: PromptTarget + prompt: Prompt + context: (ContextItem & { key: string })[] +}) { + let target = input.target + let cleared: Prompt | undefined + + return { + prompt: input.prompt, + context: input.context, + target: () => target, + clear() { + target.reset() + cleared = target.current() + }, + retarget(next: PromptTarget) { + input.context.forEach(next.context.add) + target = next + }, + current: (value: PromptTarget) => target === value, + restore() { + if (cleared !== undefined && target.current() !== cleared) return + return { target, prompt: input.prompt, context: input.context } + }, + } +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 43bdafefa41..6e940040c71 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -41,6 +41,7 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, + capture: () => prompt, } const clientFor = (directory: string) => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 814c7c8409b..2a23a560a0f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" -import type { FileSelection } from "@/context/file" import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" @@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" +import { createPromptSubmissionState } from "./submission-state" type PendingPrompt = { abort: AbortController @@ -194,15 +194,6 @@ type PromptSubmitInput = { onSubmit?: () => void } -type CommentItem = { - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - export function createPromptSubmit(input: PromptSubmitInput) { const navigate = useNavigate() const sdk = useSDK() @@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch(() => {}) } - const restoreCommentItems = (items: CommentItem[]) => { + const restoreCommentItems = ( + target: ReturnType["capture"]>, + items: (ContextItem & { key: string })[], + ) => { for (const item of items) { - prompt.context.add({ + target.context.add({ type: "file", path: item.path, selection: item.selection, @@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const removeCommentItems = (items: { key: string }[]) => { - for (const item of items) { - prompt.context.remove(item.key) - } - } - - const clearContext = () => { - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) + const clearContext = (target: ReturnType["capture"]>) => { + for (const item of target.context.items()) { + target.context.remove(item.key) } } @@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() - const currentPrompt = prompt.current() + const target = prompt.capture() + const submission = createPromptSubmissionState({ + target, + prompt: target.current(), + context: target.context.items().slice(), + }) + const currentPrompt = submission.prompt + const context = submission.context const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() @@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const draftID = search.draftId if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) } } if (!session) { @@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { providerID: currentModel.provider.id, } const agent = currentAgent.name - const context = prompt.context.items().slice() const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, @@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const clearInput = () => { - prompt.reset() + submission.clear() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { - prompt.set(currentPrompt, input.promptLength(currentPrompt)) + const restored = submission.restore() + if (!restored) return false + restored.target.set(restored.prompt, input.promptLength(restored.prompt)) + if (!submission.current(prompt.capture())) return true input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { @@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { setCursorPosition(editor, input.promptLength(currentPrompt)) input.queueScroll() }) + return true } if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { input.onQueue?.(draft) - clearContext() + clearContext(submission.target()) clearInput() return } @@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - removeCommentItems(commentItems) + for (const item of commentItems) submission.target().context.remove(item.key) clearInput() const waitForWorktree = async () => { @@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) } pending.set(pendingKey(session.id), { abort: controller, cleanup }) @@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: errorMessage(err), }) removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts new file mode 100644 index 00000000000..c8abf9a7668 --- /dev/null +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -0,0 +1,43 @@ +import { createComputed, on, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { PromptHistoryEntry } from "./history" + +export type PromptInputTransientState = { + popover: "at" | "slash" | null + historyIndex: number + savedPrompt: PromptHistoryEntry | null + placeholder: number + draggingType: "image" | "@mention" | null + mode: "normal" | "shell" + applyingHistory: boolean + variantOpen: boolean +} + +function resetPromptInputTransientState(setStore: SetStoreFunction) { + setStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) +} + +export function createPromptInputTransientState(identity: Accessor, placeholder: number) { + const [store, setStore] = createStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + placeholder, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) + + createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) + + return [store, setStore] as const +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c61afb262c0..95a76d6e848 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const file = useFile() const layout = useLayout() const language = useLanguage() - const providers = useProviders() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a44bfa2ec73..e5fa48a914a 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" @@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = [] export function SessionContextTab() { const sync = useSync() const language = useLanguage() - const providers = useProviders() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) const { params, view } = useSessionLayout() const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) diff --git a/packages/app/src/components/titlebar-tab-drag.test.ts b/packages/app/src/components/titlebar-tab-drag.test.ts new file mode 100644 index 00000000000..5c1451fc305 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag" +import { + canOpenTabRename, + captureTabPointerDown, + canStartTabDrag, + createTabDragPreview, + forwardTabRef, + isPrimaryPointerPressed, + isTabCloseTarget, +} from "./titlebar-tab-gesture" + +describe("titlebar tab drag", () => { + const layout = { + listLeft: 100, + dividerWidth: 13, + tabWidthById: new Map([ + ["a", 40], + ["b", 40], + ["c", 40], + ["d", 40], + ]), + } + + test("moves across multiple tabs from one pointer update", () => { + expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3) + expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0) + }) + + test("keeps the current index inside the left hysteresis deadband", () => { + expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1) + }) + + test("includes slot margins in captured divider width", () => { + const list = document.createElement("div") + const first = document.createElement("div") + const second = document.createElement("div") + const firstTab = document.createElement("div") + const secondTab = document.createElement("div") + first.dataset.titlebarTabSlot = "" + first.dataset.tabKey = "a" + second.dataset.titlebarTabSlot = "" + second.dataset.tabKey = "b" + second.style.marginLeft = "6px" + firstTab.dataset.titlebarTab = "" + secondTab.dataset.titlebarTab = "" + first.append(firstTab) + second.append(secondTab) + list.append(first, second) + document.body.append(list) + firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect + secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect + second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect + list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect + + expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13) + list.remove() + }) +}) + +describe("titlebar tab gestures", () => { + test("excludes close controls from tab gestures", () => { + const close = document.createElement("div") + const button = document.createElement("button") + const link = document.createElement("a") + close.dataset.slot = "tab-close" + close.append(button) + expect(isTabCloseTarget(close)).toBe(true) + expect(isTabCloseTarget(button)).toBe(true) + expect(isTabCloseTarget(link)).toBe(false) + }) + + test("forwards component refs", () => { + const element = document.createElement("div") + let received: HTMLDivElement | undefined + forwardTabRef((value) => (received = value), element) + expect(received).toBe(element) + }) + + test("does not reopen rename while a save is pending", () => { + expect(canOpenTabRename(false, false, false)).toBe(true) + expect(canOpenTabRename(false, false, true)).toBe(false) + }) + + test("keeps the rendered tab content in the drag preview", () => { + const tab = document.createElement("div") + tab.innerHTML = 'Session' + const preview = createTabDragPreview(tab) + expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull() + expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session") + }) + + test("captures the grab offset before navigation scrolls the tab", () => { + const tab = document.createElement("div") + tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect + + expect(captureTabPointerDown(tab, 100, 20)).toEqual({ + startX: 100, + startY: 20, + grabOffsetX: 20, + grabOffsetY: 10, + width: 120, + element: tab, + }) + }) + + test("detects when the primary pointer button was released outside the window", () => { + expect(isPrimaryPointerPressed(1)).toBe(true) + expect(isPrimaryPointerPressed(3)).toBe(true) + expect(isPrimaryPointerPressed(0)).toBe(false) + expect(isPrimaryPointerPressed(2)).toBe(false) + }) + + test("preserves native panning for touch pointers", () => { + expect(canStartTabDrag("mouse")).toBe(true) + expect(canStartTabDrag("pen")).toBe(true) + expect(canStartTabDrag("touch")).toBe(false) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts new file mode 100644 index 00000000000..fb1cf7fe73c --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -0,0 +1,135 @@ +export type TabDragLayout = { + tabWidthById: Map + dividerWidth: number + listLeft: number +} + +export const ACTIVATION_DISTANCE = 4 +export const HYSTERESIS_DEADBAND = 8 +export const AUTOSCROLL_EDGE = 24 +export const AUTOSCROLL_MAX_SPEED = 8 +export const FLOATER_OVERSHOOT_MAX = 8 + +export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { + const dx = x2 - x1 + const dy = y2 - y1 + return Math.sqrt(dx * dx + dy * dy) +} + +export function captureTabDragLayout(list: HTMLElement, order: string[]) { + const tabWidthById = new Map() + const slots = list.querySelectorAll("[data-titlebar-tab-slot]") + for (const slot of slots) { + const id = slot.dataset.tabKey + if (!id) continue + const tab = slot.querySelector("[data-titlebar-tab]") + if (!tab) continue + tabWidthById.set(id, tab.getBoundingClientRect().width) + } + + let dividerWidth = 0 + if (order.length >= 2) { + const secondId = order[1] + for (const slot of slots) { + if (slot.dataset.tabKey !== secondId) continue + const tab = slot.querySelector("[data-titlebar-tab]") + if (!tab) break + const style = getComputedStyle(slot) + dividerWidth = + slot.getBoundingClientRect().width - + tab.getBoundingClientRect().width + + (Number.parseFloat(style.marginLeft) || 0) + + (Number.parseFloat(style.marginRight) || 0) + break + } + } + + return { + tabWidthById, + dividerWidth, + listLeft: list.getBoundingClientRect().left, + } +} + +export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) { + layout.listLeft = list.getBoundingClientRect().left +} + +function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) { + const id = order[index] + if (!id) return 0 + const tabWidth = layout.tabWidthById.get(id) ?? 0 + return index === 0 ? tabWidth : layout.dividerWidth + tabWidth +} + +function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) { + let left = layout.listLeft + for (let i = 0; i < index; i++) { + left += slotWidthAt(order, i, layout) + } + return left +} + +export function insertIndexFromVirtualLayout( + pointerX: number, + order: readonly string[], + draggedId: string, + currentIndex: number, + layout: TabDragLayout, + deadband = HYSTERESIS_DEADBAND, +) { + if (order.length === 0) return 0 + + const others = order.filter((id) => id !== draggedId) + let target = currentIndex + + while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target-- + while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++ + + return target +} + +export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) { + const fromIndex = order.indexOf(draggedId) + if (fromIndex === -1 || fromIndex === toIndex) return [...order] + const next = [...order] + next.splice(toIndex, 0, ...next.splice(fromIndex, 1)) + return next +} + +export function draftOrderChanged(initial: readonly string[], final: readonly string[]) { + if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false + return final.some((key, index) => key !== initial[index]) +} + +function easeOvershoot(overshoot: number) { + return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX) +} + +export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) { + const stripWidth = stripRight - stripLeft + if (width >= stripWidth) return stripLeft + + const maxLeft = stripRight - width + if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft) + if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left) + + return left +} + +export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) { + const leftEdge = containerLeft + AUTOSCROLL_EDGE + const rightEdge = containerRight - AUTOSCROLL_EDGE + + if (pointerX < leftEdge) { + const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE + return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) + } + + if (pointerX > rightEdge) { + const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE + return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) + } + + return 0 +} diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts new file mode 100644 index 00000000000..02866d41116 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -0,0 +1,37 @@ +import type { Ref } from "solid-js" + +export function isTabCloseTarget(target: EventTarget | null) { + return target instanceof Element && !!target.closest('[data-slot="tab-close"]') +} + +export function canStartTabDrag(pointerType: string) { + return pointerType !== "touch" +} + +export function isPrimaryPointerPressed(buttons: number) { + return (buttons & 1) !== 0 +} + +export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) { + const rect = element.getBoundingClientRect() + return { + startX: clientX, + startY: clientY, + grabOffsetX: clientX - rect.left, + grabOffsetY: clientY - rect.top, + width: rect.width, + element, + } +} + +export function forwardTabRef(ref: Ref | undefined, element: HTMLDivElement) { + if (typeof ref === "function") ref(element) +} + +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { + return !dragging && !editing && !committing +} + +export function createTabDragPreview(element: HTMLDivElement) { + return element.cloneNode(true) as HTMLDivElement +} diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css new file mode 100644 index 00000000000..bc1beaf0ea1 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -0,0 +1,69 @@ +[data-titlebar-tab] [data-slot="tab-close"] { + position: absolute; + top: 0; + right: 0; + display: flex; + height: 28px; + width: 28px; + align-items: center; + justify-content: center; +} + +[data-titlebar-tab] [data-slot="tab-close"]::before { + content: ""; + position: absolute; + top: 0; + height: 28px; + width: 16px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--tab-bg)); + display: none; +} + +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { + display: block; + right: 0; +} + +[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, +[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) + [data-slot="tab-close"]::before { + right: 100%; +} + +[data-titlebar-tab] [data-slot="tab-link"] { + padding-right: 22px; +} + +[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + padding-right: 0; +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] { + padding-right: 22px; +} + +[data-titlebar-tab] [data-slot="tab-title"] { + min-width: 0; +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] { + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + scrollbar-width: none; +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar { + display: none; +} + +[data-titlebar-tab]:hover [data-slot="tab-close"], +[data-titlebar-tab][data-active="true"] [data-slot="tab-close"], +[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { + background: var(--tab-bg); +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 00000000000..aa821678475 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -0,0 +1,344 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { projectForSession } from "@/pages/layout/helpers" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import { showToast } from "@/utils/toast" +import type { Session } from "@opencode-ai/sdk/v2" +import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" +import "./titlebar-tab-nav.css" + +export function TabNavItem(props: { + ref?: Ref + href: string + server: ServerConnection.Key + session: () => Session | undefined + onTitleChange?: (title: string) => void + onTitleChangeFailed?: (title: string) => void + onClose: () => void + onNavigate: () => void + active?: boolean + activeServer: boolean + forceTruncate?: boolean + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + const language = useLanguage() + const [editing, setEditing] = createSignal(false) + const [titleOverflowing, setTitleOverflowing] = createSignal(false) + let tabRoot!: HTMLDivElement + let titleEl!: HTMLSpanElement + let committing = false + let measureFrame: number | undefined + + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + const global = useGlobal() + const serverCtx = createMemo(() => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) + if (conn) return global.ensureServerCtx(conn) + }) + const project = createMemo(() => { + const session = props.session() + if (!session) return + return projectForSession(session, serverCtx()?.projects.list() ?? []) + }) + + const measureTitleOverflow = () => { + if (!titleEl || editing()) { + setTitleOverflowing(false) + return + } + setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth) + } + + const scheduleTitleOverflow = () => { + if (measureFrame !== undefined) return + measureFrame = requestAnimationFrame(() => { + measureFrame = undefined + measureTitleOverflow() + }) + } + + createEffect(() => { + props.session()?.title + props.forceTruncate + editing() + scheduleTitleOverflow() + }) + + createResizeObserver(() => tabRoot, scheduleTitleOverflow) + onCleanup(() => { + if (measureFrame !== undefined) cancelAnimationFrame(measureFrame) + }) + + const selectTitle = () => { + const range = document.createRange() + range.selectNodeContents(titleEl) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + } + + const rename = async (title: string) => { + const ctx = serverCtx() + const session = props.session() + if (!ctx || !session) return + const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) + await client.session.update({ sessionID: session.id, title }) + } + + const closeRename = async (save: boolean) => { + if (committing || !editing()) return + committing = true + + const original = props.session()?.title ?? "" + const next = (titleEl.textContent ?? "").trim() + + titleEl.scrollLeft = 0 + if (save && next && next !== original) props.onTitleChange?.(next) + setEditing(false) + + if (!save || !next || next === original) { + committing = false + return + } + + try { + await rename(next) + } catch (err) { + props.onTitleChangeFailed?.(original) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : undefined, + }) + } + + committing = false + } + + createEffect(() => { + if (editing()) return + if (!titleEl) return + const title = props.session()?.title + if (title === undefined) return + titleEl.textContent = title + }) + + const openRename = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (!canOpenTabRename(props.dragging, editing(), committing)) return + const session = props.session() + if (!session) return + titleEl.textContent = session.title + setEditing(true) + + requestAnimationFrame(() => { + titleEl.focus() + selectTitle() + }) + } + + createEffect(() => { + if (!editing()) return + + const cleanup = makeEventListener( + document, + "pointerdown", + (event) => { + const target = event.target + if (!(target instanceof Node)) return + if (tabRoot.contains(target)) return + void closeRename(true) + }, + { capture: true }, + ) + + onCleanup(cleanup) + }) + + return ( +
{ + tabRoot = el + forwardTabRef(props.ref, el) + }} + data-titlebar-tab + data-title-overflow={titleOverflowing()} + data-editing={editing()} + class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + classList={{ invisible: props.hidden }} + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) + }} + > + + {(session) => { + return ( + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = session().title + }} + data-slot="tab-title" + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return + event.preventDefault() + titleEl.textContent = session().title + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() + }} + onClick={(event) => { + if (!editing()) return + event.preventDefault() + }} + /> + + ) + }} + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + /> +
+
+ ) +} + +export function DraftTabItem(props: { + ref?: Ref + href: string + title: string + active?: boolean + onNavigate: () => void + onClose: () => void + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + return ( +
forwardTabRef(props.ref, el)} + data-titlebar-tab + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]" + classList={{ invisible: props.hidden }} + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) + }} + > + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]" + > + + + + {props.title} + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + aria-label="Close tab" + /> +
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 00000000000..c5e39a26887 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -0,0 +1,581 @@ +import { + createEffect, + createMemo, + createResource, + createRoot, + createSignal, + For, + onCleanup, + onMount, + Show, +} from "solid-js" +import { Portal } from "solid-js/web" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" +import { ServerConnection } from "@/context/server" +import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" +import { useGlobal, type ServerCtx } from "@/context/global" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { useTabs } from "@/context/tabs" +import { createTabPromptState } from "@/context/prompt" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { + captureTabPointerDown, + canStartTabDrag, + createTabDragPreview, + isPrimaryPointerPressed, + isTabCloseTarget, +} from "./titlebar-tab-gesture" +import { + ACTIVATION_DISTANCE, + autoscrollSpeed, + captureTabDragLayout, + clampFloaterLeft, + draftOrderChanged, + insertIndexFromVirtualLayout, + movePlaceholder, + pointerDistance, + syncLayoutScroll, + type TabDragLayout, +} from "@/components/titlebar-tab-drag" + +function SessionTabSlot(props: { + tab: SessionTab + id: string + first: () => boolean + active: () => boolean + activeServerKey: ServerConnection.Key + forceTruncate: boolean + dragActive: boolean + dragged: () => boolean + pressed: () => boolean + serverCtx: () => ServerCtx | undefined + suppressNavigation: () => boolean + onPointerDown: (event: PointerEvent) => void + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const tabs = useTabs() + let ref!: HTMLDivElement + const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) + const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) + const [loadedSession] = createResource( + () => { + const ctx = props.serverCtx() + return ctx ? { id: props.tab.sessionId, ctx } : null + }, + ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), + ) + const session = createMemo(() => cachedSession() ?? loadedSession()) + let prefetched = false + + createEffect(() => { + const ctx = props.serverCtx() + const value = session() + if (!ctx || !value || prefetched) return + prefetched = true + createRoot((dispose) => { + try { + void ctx.sync + .ensureDirSyncContext(value.directory) + .session.sync(value.id) + .catch(() => {}) + .finally(dispose) + } catch { + dispose() + } + }) + }) + + createEffect(() => { + const value = session() + const current = sdk() + if (!value || !current) return + createTabPromptState(tabs, props.tab, current.scope, { + dir: base64Encode(value.directory), + id: value.id, + }) + }) + + return ( +
+ { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onTitleChangeFailed={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + activeServer={props.tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + suppressNavigation={props.suppressNavigation} + pressed={props.pressed()} + hidden={props.dragged()} + /> +
+ ) +} + +export function TitlebarTabStrip(props: { + tabs: Tab[] + currentTab: () => Tab | undefined + activeServerKey: ServerConnection.Key + forceTruncate: boolean + onNavigate: (tab: Tab, el?: HTMLDivElement) => void + onClose: (tab: Tab) => void + onReorder: (keys: string[]) => void + onOverflowChange: (overflowing: boolean) => void +}) { + const global = useGlobal() + const language = useLanguage() + const [drag, setDrag] = createStore({ + active: false, + draggedId: undefined as string | undefined, + placeholderIndex: 0, + draftOrder: [] as string[], + initialOrder: [] as string[], + draggedWidth: 0, + pointerX: 0, + grabOffsetX: 0, + floaterTop: 0, + }) + + const [gesture, setGesture] = createStore({ + pending: undefined as + | { + id: string + startX: number + startY: number + grabOffsetX: number + grabOffsetY: number + pointerId: number + width: number + element: HTMLDivElement + } + | undefined, + }) + + const [suppressNavigation, setSuppressNavigation] = createSignal(false) + const [pressedId, setPressedId] = createSignal() + const [stripScrollLeft, setStripScrollLeft] = createSignal(0) + let scrollRef!: HTMLDivElement + let listRef!: HTMLDivElement + let dragLayout: TabDragLayout | undefined + let dragPointerId: number | undefined + let autoscrollFrame: number | undefined + let resizeFrame: number | undefined + let dragPreview: HTMLDivElement | undefined + + const tabIds = () => props.tabs.map(tabKey) + + const displayTabs = createMemo(() => { + if (!drag.active || drag.draftOrder.length === 0) return props.tabs + const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab])) + return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab) + }) + + function refreshOverflow() { + if (!scrollRef) return + props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) + } + + createResizeObserver( + () => [scrollRef, listRef], + () => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + refreshOverflow() + if (!drag.active || !listRef) return + dragLayout = captureTabDragLayout(listRef, drag.draftOrder) + updateInsertIndex() + }) + }, + ) + + function syncScroll() { + if (!scrollRef || !listRef || !dragLayout) return + syncLayoutScroll(listRef, dragLayout) + setStripScrollLeft(scrollRef.scrollLeft) + updateInsertIndex() + } + + function stopAutoscroll() { + if (autoscrollFrame === undefined) return + cancelAnimationFrame(autoscrollFrame) + autoscrollFrame = undefined + } + + function tickAutoscroll() { + if (!drag.active || !scrollRef) return + + const strip = scrollRef.getBoundingClientRect() + const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right) + + if (speed !== 0) { + scrollRef.scrollLeft += speed + syncScroll() + } + + autoscrollFrame = requestAnimationFrame(tickAutoscroll) + } + + function startAutoscroll() { + stopAutoscroll() + autoscrollFrame = requestAnimationFrame(tickAutoscroll) + } + + function applyPlaceholderIndex(nextIndex: number) { + const id = drag.draggedId + if (!id) return + const next = movePlaceholder(drag.draftOrder, id, nextIndex) + setDrag({ + draftOrder: next, + placeholderIndex: nextIndex, + }) + } + + function updateInsertIndex() { + if (!drag.active || !dragLayout) return + const draggedId = drag.draggedId + if (!draggedId) return + const nextIndex = insertIndexFromVirtualLayout( + drag.pointerX, + drag.draftOrder, + draggedId, + drag.placeholderIndex, + dragLayout, + ) + if (nextIndex === drag.placeholderIndex) return + applyPlaceholderIndex(nextIndex) + } + + function startDrag(id: string) { + const order = tabIds() + const index = order.indexOf(id) + const pending = gesture.pending + if (index === -1 || !pending || !listRef || !scrollRef) return + + dragLayout = captureTabDragLayout(listRef, order) + dragPreview = createTabDragPreview(pending.element) + dragPointerId = pending.pointerId + setGesture("pending", undefined) + + setDrag({ + active: true, + draggedId: id, + placeholderIndex: index, + draftOrder: order, + initialOrder: order, + draggedWidth: pending.width, + pointerX: pending.startX, + grabOffsetX: pending.grabOffsetX, + floaterTop: pending.startY - pending.grabOffsetY, + }) + setPressedId(undefined) + setStripScrollLeft(scrollRef.scrollLeft) + startAutoscroll() + } + + function endDrag(commit: boolean) { + const initial = drag.initialOrder + const final = drag.draftOrder + const moved = drag.active + + if (commit && moved && draftOrderChanged(initial, final)) { + props.onReorder(final) + } + + if (moved) setSuppressNavigation(true) + + setDrag({ + active: false, + draggedId: undefined, + placeholderIndex: 0, + draftOrder: [], + initialOrder: [], + draggedWidth: 0, + pointerX: 0, + grabOffsetX: 0, + floaterTop: 0, + }) + + dragLayout = undefined + dragPreview = undefined + dragPointerId = undefined + setGesture("pending", undefined) + setPressedId(undefined) + stopAutoscroll() + refreshOverflow() + requestAnimationFrame(() => setSuppressNavigation(false)) + } + + function onPointerDown(id: string, event: PointerEvent) { + if (event.button !== 0 || drag.active) return + if (!canStartTabDrag(event.pointerType)) return + if (isTabCloseTarget(event.target)) return + const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-titlebar-tab]") + if (!tabEl) return + if (!tabEl.querySelector('[data-slot="tab-link"]')) return + const tab = props.tabs.find((item) => tabKey(item) === id) + if (!tab) return + const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY) + setSuppressNavigation(true) + props.onNavigate(tab, tabEl) + setPressedId(id) + setGesture("pending", { + id, + pointerId: event.pointerId, + ...pointer, + }) + } + + function onPointerMove(event: PointerEvent) { + const pending = gesture.pending + if (pending && event.pointerId !== pending.pointerId) return + if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return + if (!isPrimaryPointerPressed(event.buttons)) { + if (drag.active) endDrag(true) + if (pending) { + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + return + } + + if (pending && !drag.active) { + if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return + startDrag(pending.id) + } + + if (!drag.active) return + + setDrag("pointerX", event.clientX) + syncScroll() + } + + function onPointerUp(event: PointerEvent) { + if (drag.active) { + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return + setDrag("pointerX", event.clientX) + syncScroll() + endDrag(true) + return + } + + const pending = gesture.pending + if (pending && event.pointerId !== pending.pointerId) return + + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + + function onPointerCancel(event: PointerEvent) { + if (drag.active) { + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return + endDrag(false) + return + } + + if (!gesture.pending) return + if (gesture.pending.pointerId !== event.pointerId) return + setGesture("pending", undefined) + setPressedId(undefined) + requestAnimationFrame(() => setSuppressNavigation(false)) + } + + onMount(() => { + const cleanups = [ + makeEventListener(window, "pointermove", onPointerMove), + makeEventListener(window, "pointerup", onPointerUp), + makeEventListener(window, "pointercancel", onPointerCancel), + ] + refreshOverflow() + onCleanup(() => cleanups.forEach((cleanup) => cleanup())) + }) + + onCleanup(stopAutoscroll) + onCleanup(() => { + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + }) + + createEffect(() => { + props.tabs.length + tabIds() + refreshOverflow() + }) + + createEffect(() => { + if (!drag.active || !scrollRef) return + onCleanup(makeEventListener(scrollRef, "scroll", syncScroll)) + }) + + const floaterStyle = () => { + stripScrollLeft() + const strip = scrollRef?.getBoundingClientRect() + const left = strip + ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right) + : drag.pointerX - drag.grabOffsetX + + return { + position: "fixed" as const, + top: `${drag.floaterTop}px`, + left: `${left}px`, + width: `${drag.draggedWidth}px`, + "z-index": "10000", + "pointer-events": "none" as const, + } + } + + const draggedTab = createMemo(() => { + const id = drag.draggedId + if (!id) return + return props.tabs.find((tab) => tabKey(tab) === id) + }) + + return ( + <> +
+
+
+ + {(tab, index) => { + const id = tabKey(tab) + const first = () => index() === 0 + let ref!: HTMLDivElement + useTabShortcut(index, () => props.onNavigate(tab, ref)) + + const dragged = () => drag.active && drag.draggedId === id + const serverCtx = createMemo(() => { + if (tab.type !== "session") return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) + if (conn) return global.ensureServerCtx(conn) + }) + + if (tab.type === "session") { + return ( + props.currentTab() === tab} + activeServerKey={props.activeServerKey} + forceTruncate={props.forceTruncate} + dragActive={drag.active} + dragged={dragged} + pressed={() => pressedId() === id} + serverCtx={serverCtx} + suppressNavigation={() => suppressNavigation()} + onPointerDown={(event) => { + if (dragged()) return + onPointerDown(id, event) + }} + onNavigate={(element) => props.onNavigate(tab, element)} + onClose={() => props.onClose(tab)} + /> + ) + } + + return ( +
{ + if (dragged()) return + onPointerDown(id, event) + }} + > + props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + suppressNavigation={() => suppressNavigation()} + active={props.currentTab() === tab} + pressed={pressedId() === id} + hidden={dragged()} + /> +
+ ) + }} +
+
+
+