Compare commits

...
38 Commits
Author SHA1 Message Date
Aiden Cline 9553187ba6 fix(core): allow patch file-to-directory replacements (#46476) 2026-08-31 16:24:04 -05:00
Aiden Cline d04257eeb4 feat(codemode): add inline namespace metadata (#46464) 2026-08-31 16:01:14 -05:00
opencode-agent[bot]andrekram1-node d68f425c17 feat(cli): port the upgrade command to v2 (#46183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 16:00:22 -05:00
Aiden Cline 49dd2cea34 refactor(ai): clarify responses adapters (#46469) 2026-08-31 15:36:57 -05:00
Simon Klee fac875dba0 mini v2 v2 (#46410) 2026-08-31 22:03:27 +02:00
Kit Langton 566ca864a0 refactor(util): reuse private patch buffers (#46459) 2026-08-31 15:11:54 -04:00
Dax Raad 5df9cecf03 fix(tui): remove plugin current marker 2026-08-31 14:31:03 -04:00
Dax Raad a68fe8a97d fix(tui): toggle plugin on dialog submit 2026-08-31 14:28:19 -04:00
Dax Raad c17c104827 fix(tui): toggle internal plugin controls 2026-08-31 14:25:06 -04:00
Dax Raad 5d4cc4a804 feat(tui): hide internal plugins by default 2026-08-31 14:25:06 -04:00
Kit Langton 1f04baa684 test: migrate fixture layer replacements (#46458)
Update the Core compile options and Server replacement values to the current LayerNode API. Preserve test expectations, replacement targets, and layer lifetimes.
2026-08-31 14:16:40 -04:00
Kit Langton 3e9b009642 feat(core): add session-aware instance selection (#46442) 2026-08-31 13:46:33 -04:00
Kit Langton 36ac35a7c8 refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00
Kit Langton 197d28e033 fix(tui): pin sidebar headings without scrollbar flashes (#46449)
Keep the title and workspace label above scrollable sidebar details. Disable the unused horizontal scrollbar and place the automatic vertical scrollbar in the reserved gutter so tab changes do not flash or shift the sidebar.
2026-08-31 13:45:55 -04:00
opencode-agent[bot]andkitlangton fcce2d7cc9 test(tui): await dialog text selection (#46143)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-08-31 13:44:00 -04:00
Dax Raad ec0dcb3da9 docs: improve build documentation discovery 2026-08-31 12:52:59 -04:00
Kit Langton afd7492018 fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00
Kit Langton 9517ff1054 fix(core): preserve active session continuation when moving 2026-08-31 12:14:45 -04:00
Kit Langton 1ced747051 fix(ai): handle message-less Gemini errors (#46069) 2026-08-31 12:14:31 -04:00
opencode-agent[bot]andDavid 43819dc376 fix(app): restore maskable pwa icons (#46434)
Co-authored-by: David <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 00:11:07 +08:00
Kit Langton e15dd8ecd3 fix(ai): require Bedrock message stop for finish (#46065) 2026-08-31 12:03:22 -04:00
Dax 6a38cacc1d docs: improve plugin guide readability (#46342) 2026-08-31 11:48:04 -04:00
Kit Langton d609752891 refactor(codemode): avoid merging root definitions twice (#46081) 2026-08-31 11:40:17 -04:00
Brendan Allan 5894e46688 fix(app): improve touch controls and standalone PWA relaunch (#46391) 2026-08-31 23:35:40 +08:00
Kit Langton 327dc809c5 refactor(core): reuse formatter file extension (#46080) 2026-08-31 11:30:57 -04:00
Kit Langton e9f7331516 refactor(core): reuse Markdown chunk byte counts (#46079) 2026-08-31 11:30:49 -04:00
Kit Langton 8be3ce8b6c refactor(util): reuse BOM-stripped text (#46078) 2026-08-31 11:30:40 -04:00
Kit Langton 30721b8b5d fix(server): await providers before catalog reads (#46066) 2026-08-31 11:24:56 -04:00
Kit Langton eb083cce63 fix(server): detach PTYs when sockets close (#46068) 2026-08-31 10:53:48 -04:00
1afb7c614e fix(app): raise active composer surface (#46401)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-31 22:47:49 +08:00
Kit Langton 56e773831c fix(ai): validate cache tail counts (#46067) 2026-08-31 10:46:18 -04:00
Brendan Allan 6b1ed3918a feat(app): refine mobile diff review and wrapping preferences (#46390) 2026-08-31 22:32:11 +08:00
opencode-agent[bot]andvimtor 1b3eb1138e fix(tui): queue autocompleted commands (#46414)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-08-31 19:52:29 +05:30
Brendan Allan 711a0a2da2 feat(app): add mobile session panels and detail drawers (#46389) 2026-08-31 14:04:14 +00:00
opencode-agent[bot]andnexxeln ac77cc46b8 fix(client): isolate shared event consumers (#46393)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-31 19:32:27 +05:30
opencode-agent[bot] 7197fdfb4e chore: update nix node_modules hashes 2026-08-31 13:40:50 +00:00
Brendan Allan 5a4914c670 feat(app): refine mobile shell home and settings (#46388) 2026-08-31 21:20:17 +08:00
Kit Langton 3a797bf6e4 fix(ai): validate canonical tool results (#46062) 2026-08-31 09:14:31 -04:00
328 changed files with 14371 additions and 4367 deletions
+15
View File
@@ -54,6 +54,7 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -1642,6 +1643,10 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -2876,6 +2881,8 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
@@ -5302,6 +5309,10 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5316,6 +5327,8 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
@@ -6630,6 +6643,8 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+16 -20
View File
@@ -510,9 +510,10 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -692,12 +693,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
},
[],
@@ -705,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
usage,
},
[],
] as const
@@ -736,18 +731,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
if (!state.finishReason) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
...state.finishReason,
normalized,
},
usage: state.pendingFinish.usage,
usage: state.usage,
})
return events
}
@@ -771,7 +766,8 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
+11 -2
View File
@@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
+40 -39
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| HostedToolReplayItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface Extension {
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -482,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -507,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return lowered
})
@@ -526,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
"tool-result",
)
})
@@ -540,30 +540,33 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -571,13 +574,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -644,7 +647,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
: adapter.restoreHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -658,13 +661,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -677,11 +678,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
output: yield* lowerToolResultOutput(part, request, adapter),
})
}
}
@@ -733,28 +734,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -768,7 +769,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
// =============================================================================
@@ -1408,9 +1409,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
adapter,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
+1 -6
View File
@@ -9,7 +9,6 @@ import {
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -56,11 +55,6 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
@@ -80,6 +74,7 @@ const toolResultValueSchema = Schema.Union([
}),
]).annotate({ identifier: "LLM.ToolResult" })
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
const isToolResultValue = Schema.is(toolResultValueSchema)
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
+1 -1
View File
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
Schema.Struct({ tail: Schema.Natural }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
@@ -631,10 +631,45 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -62,6 +62,62 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
+32
View File
@@ -21,6 +21,7 @@ import {
QuotaExceededError,
RateLimitError,
RouteID,
ToolResultValue,
TransportError,
UnknownProviderError,
Usage,
@@ -83,6 +84,37 @@ describe("llm schema", () => {
})
})
describe("ToolResultValue", () => {
test("uses the canonical schema guard", () => {
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
{ value: { type: "json", value: { ok: true } }, expected: true },
{ value: { type: "text", value: "done" }, expected: true },
{ value: { type: "error", value: "failed" }, expected: true },
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
{ value: { type: "content", value: "done" }, expected: false },
{ value: { type: "json" }, expected: false },
{ value: { type: "unknown", value: "done" }, expected: false },
]
for (const item of cases) {
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
expect(ToolResultValue.is(item.value)).toBe(item.expected)
}
})
test("accepts canonical results with extra fields", () => {
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
expect(
ToolResultValue.is({
type: "content",
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
metadata: { source: "tool" },
}),
).toBe(true)
})
})
describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
+27
View File
@@ -442,6 +442,33 @@ describe("LLMClient tools", () => {
}),
)
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
Effect.gen(function* () {
const malformed = { type: "content", value: [{ type: "text" }] }
const dynamic = Tool.make({
description: "Return caller-defined JSON.",
jsonSchema: { type: "object", properties: {} },
execute: () => Effect.succeed(malformed),
})
const dispatched = yield* ToolRuntime.dispatch(
{ dynamic },
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
)
expect(dispatched.result).toEqual({ type: "json", value: malformed })
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "dynamic",
result: { type: "json", value: malformed },
output: { structured: malformed, content: [] },
}),
])
}),
)
it.effect("executes tool calls for one step without looping by default", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
@@ -8,6 +8,30 @@ story.beforeEach(async ({ mount }) => {
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
const content = root.locator("[data-timeline-virtual-content]")
await expect(content).toHaveCSS("visibility", "visible")
const gap = () =>
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
const viewport = element.closest("[data-scrollable]")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
})
await expect.poll(gap).toBe(16)
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect.poll(gap).toBe(16)
await page.setViewportSize({ width: 1280, height: 900 })
await expect.poll(gap).toBe(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(gap).toBe(16)
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: (path) =>
path
? []
: ["first.ts", "second.ts"].map((name) => ({
name,
path: name,
absolute: `${fixture.directory}/${name}`,
type: "file",
ignored: false,
})),
fileContent: (path) => `contents:${path}`,
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
await expect
.poll(() =>
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
const header = element.closest('[data-slot="session-mobile-files-header"]')!
const separator = getComputedStyle(element, "::before")
return {
height: separator.height,
fullWidth: parseFloat(separator.width) === header.clientWidth,
start: separator.insetInlineStart,
bottom: separator.bottom,
}
}),
)
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files.locator('[data-column-number="1"]').click()
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
await expect(editor.getByRole("textbox")).toBeVisible()
for (const width of [390, 700]) {
await page.setViewportSize({ width, height: 844 })
await expect
.poll(async () => {
const panel = await files.boundingBox()
const comment = await editor.boundingBox()
if (!panel || !comment) return false
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
})
.toBe(true)
}
await editor.getByRole("textbox").fill("Full-width file comment")
await editor.getByRole("button", { name: "Comment", exact: true }).click()
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
await expect(comment).toContainText("Full-width file comment")
await expect
.poll(async () => {
const panel = await files.boundingBox()
const card = await comment.boundingBox()
if (!panel || !card) return false
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
})
.toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
await files.getByRole("button", { name: "All files", exact: true }).click()
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
await files.getByRole("option", { name: "second.ts", exact: true }).click()
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
await expect
.poll(() =>
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
const header = element.closest('[data-slot="session-mobile-files-header"]')!
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
}),
)
.toBe(true)
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
await files.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(openTabs.getByRole("tab")).toHaveCount(0)
})
}
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const loading = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
fileContent: async (path) => {
if (path === "second.ts") await loading.promise
return `contents:${path}`
},
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
file,
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
})),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
const files = page.locator('[data-slot="session-mobile-files"]')
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
const header = diff.getByRole("button", { name: file, exact: true })
await expect(header).toBeEnabled()
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
await diff.getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
if (file === "second.ts") loading.resolve()
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
})
@@ -0,0 +1,138 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, {
vcsDiff: [
{
file: "added.ts",
status: "added",
additions: 1,
deletions: 0,
patch:
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
},
{
file: "removed.ts",
status: "deleted",
additions: 0,
deletions: 1,
patch:
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
},
{
file: "modified.ts",
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
},
],
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
const review = page.locator('[data-component="session-review"]')
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-top-width",
"0px",
)
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-bottom-width",
"0px",
)
for (const change of [
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
]) {
const item = review.locator(`[data-file="${change.file}"]`)
const trigger = item.getByRole("button", { name: change.file, exact: true })
const summary = item.locator('[data-slot="session-review-change-summary"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
await expect(summary).toHaveCount(0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (change.file === "modified.ts") {
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
}
await expect(summary).toBeVisible()
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCount(0)
}
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
const modified = review.locator('[data-file="modified.ts"]')
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true)
await modified.locator("[data-code]").evaluate((element) => {
element.scrollLeft = 100
})
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
.toBeGreaterThan(0)
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await page.keyboard.press("Control+,")
await expect(wrap).not.toBeChecked()
await wrapControl.click()
await expect(wrap).toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
await expect
.poll(async () => {
const button = await openFile.boundingBox()
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
if (!button || !summary) return false
return direction === "ltr"
? button.x > summary.x + summary.width / 2
: button.x + button.width < summary.x + summary.width / 2
})
.toBe(true)
await openFile.click()
await expect(
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
).toHaveAttribute("aria-selected", "true")
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files).toHaveAttribute("data-browsing", "false")
})
}
@@ -0,0 +1,186 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const position of ["top", "bottom"] as const) {
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
})
await installStressSessionTabs(page)
await page.addInitScript(
(position) =>
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
position,
)
await page.route("**/api/pty*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: {
id: "pty_mobile_views",
title: "Terminal 1",
command: "sh",
args: [],
cwd: fixture.directory,
status: "running",
pid: 1,
},
},
}),
)
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
},
}),
)
await page.goto(stressSessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
await expect(drawer).toHaveAttribute("data-open", "")
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await page.getByRole("button", { name: "Back to app", exact: true }).click()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
await expect(usage).toHaveCSS("padding-inline-start", "16px")
await expect(usage).toHaveCSS("padding-inline-end", "16px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
await expect(panel.locator("textarea")).toBeEditable()
await expect(panel).toHaveCount(1)
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await expect(message).toBeVisible()
await expect(panel).toBeHidden()
await expect(panel).toHaveAttribute("inert", "")
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await page.goto(stressSessionHref(fixture.sourceID))
await expect(picker).toHaveText("Session")
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
await page.setViewportSize({ width: 1280, height: 900 })
await expect(picker).toBeHidden()
await expect(page.locator("[data-session-title]")).toBeVisible()
})
}
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
const projects = page.getByRole("button", { name: "Projects", exact: true })
await projects.click()
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
await expect(picker).toBeHidden()
await expect(projects).toContainText(fixture.project.name)
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
await expect(help).toBeVisible()
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
await expect(settings).toBeVisible()
await menu.click()
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
await expect
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
await page.mouse.down()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
await expect(overlay).toHaveCount(0)
await expect(more).toBeFocused()
}
})
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
await expect(changes).toHaveAttribute("aria-selected", "true")
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -89,26 +89,31 @@ test("cramped tabs only show the close button for the active tab", async ({ page
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
for (const direction of ["ltr", "rtl"]) {
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await page.setViewportSize({ width: 450, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
await expect(tabA).toBeVisible()
await page.setViewportSize({ width: 1280, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
await page.setViewportSize({ width: 450, height: 720 })
await page.getByRole("button", { name: "Tabs", exact: true }).click()
}
})
@@ -202,28 +207,28 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(version).toBeInViewport()
await expect(version).toHaveCSS("direction", "ltr")
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(
page
.locator('[data-slot="titlebar-tabs"]')
.locator('[data-slot="mobile-tabs-drawer"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
@@ -242,7 +247,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -259,7 +264,8 @@ test("vertical tab preference falls back to horizontal on mobile", async ({ page
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator('[data-slot="titlebar-tabs"]')
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
+6
View File
@@ -8,6 +8,12 @@ export async function expectAppVisible(locator: Locator) {
}
export async function expectSessionTitle(page: Page, title: string) {
if ((page.viewportSize()?.width ?? 1280) < 768) {
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
await expectAppVisible(trigger)
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
return
}
await expectAppVisible(page.getByRole("heading", { name: title }))
}
+2 -2
View File
@@ -9,13 +9,13 @@
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
}
],
"theme_color": "#080808",
+1
View File
@@ -56,6 +56,7 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
+3 -1
View File
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
+6 -1
View File
@@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
@@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const standalone = isStandalone()
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
@@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
</AppBaseProviders>
</PlatformProvider>
),
+6 -1
View File
@@ -2,9 +2,14 @@ import type { HomeProjectsController } from "./controller"
import { HomeProjectsView } from "./view"
import type { HomeScrollController } from "../scroll"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
export function HomeProjects(props: {
projects: HomeProjectsController
scroll: HomeScrollController
dropdown?: boolean
}) {
return (
<HomeProjectsView
dropdown={props.dropdown}
language={props.projects.copy.language}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
+131 -22
View File
@@ -1,5 +1,6 @@
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Popover } from "@kobalte/core/popover"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -28,6 +29,7 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
`project:${ServerConnection.key(server)}:${directory}`
export type HomeProjectsViewProps = {
dropdown?: boolean
language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[]
projects: LocalProject[]
@@ -64,6 +66,81 @@ export type HomeProjectsViewProps = {
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
const [state, setState] = createStore({ open: false })
const selected = createMemo(() => props.projects.find((project) => project.worktree === props.selection.directory))
const server = createMemo(() =>
props.servers.find((server) => ServerConnection.key(server) === props.selection.server),
)
return (
<Show when={props.dropdown} fallback={<HomeProjectsPanel {...props} />}>
<Popover
open={state.open}
onOpenChange={(open) => setState("open", open)}
placement="bottom-start"
sameWidth
gutter={6}
>
<Popover.Trigger
data-component="home-projects-dropdown"
aria-label={props.language.t("home.projects")}
class="flex h-10 w-full min-w-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-base px-1.5 text-start text-v2-text-text-base outline-none hover:bg-v2-background-bg-layer-01"
>
<Show when={selected()} fallback={<Icon name="folder" size="small" />}>
{(project) => <HomeProjectAvatar project={project()} />}
</Show>
<span class="flex min-w-0 flex-1 flex-col leading-[var(--line-height-compact)]">
<bdi class="truncate">
<Show when={selected()} fallback={props.language.t("home.projects.all")}>
{(project) => displayName(project())}
</Show>
</bdi>
<Show when={props.servers.length > 1 && server()}>
{(server) => (
<bdi class="truncate text-v2-text-text-muted opacity-70">
{server().displayName ?? new URL(server().http.url).host}
</bdi>
)}
</Show>
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
aria-label={props.language.t("home.projects")}
dir={props.language.direction()}
class="z-50 max-h-[min(70dvh,var(--kb-popper-content-available-height))] overflow-hidden rounded-[10px] bg-v2-background-bg-base p-1.5 shadow-[var(--v2-elevation-floating)] outline-none data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:slide-in-from-top-2 duration-150 ease-out motion-reduce:animate-none"
>
<HomeProjectsPanel
{...props}
onSelectProject={(server, directory) => {
props.onSelectProject(server, directory)
setState("open", false)
}}
onFocusServer={(server) => {
props.onFocusServer(server)
setState("open", false)
}}
onChooseProject={(server) => {
setState("open", false)
props.onChooseProject(server)
}}
onAddProjects={(server, directories) => {
setState("open", false)
props.onAddProjects(server, directories)
}}
onOpenProjectNewSession={(server, directory) => {
setState("open", false)
props.onOpenProjectNewSession(server, directory)
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
</Show>
)
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -71,48 +148,77 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
return (
<aside
class={`
class={
props.dropdown
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
: `
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
`}
`
}
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
<Show when={!props.dropdown}>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
</Show>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show when={props.dropdown && props.servers.length === 1}>
<HomeProjectNavButton
type="button"
class="mb-1"
data-selected={!props.selection.directory ? "" : undefined}
onClick={() => props.onFocusServer(props.servers[0])}
>
<Icon name="folder" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.projects.all")}</span>
</HomeProjectNavButton>
</Show>
<Show
when={props.servers.length > 1}
fallback={
<div class="pr-3">
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
<For each={props.servers}>
{(item) => {
const projects = () => props.projectsForServer(item)
@@ -157,7 +263,9 @@ export function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<div
class={`${props.class ?? ""} min-w-0 flex-row justify-between gap-1 lg:flex-col lg:justify-start lg:pr-3 [&>button]:w-auto lg:[&>button]:w-full`}
>
<HomeProjectNavButton
type="button"
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
@@ -511,6 +619,7 @@ function HomeProjectRow(
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (props.dropdown) return
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
+23 -11
View File
@@ -1,4 +1,6 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
@@ -9,6 +11,7 @@ import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const mobile = createMediaQuery("(max-width: 767px)")
const home = createHomeController()
const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home)
@@ -17,12 +20,17 @@ export function Home() {
return (
<div
class={`
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
</div>
</Show>
<ScrollView
class="h-full [container-type:size]"
class="min-h-0 flex-1 [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack()}
thumbHoverTarget={scroll.viewport.hoverTarget()}
viewportRef={scroll.viewport.setViewport}
@@ -31,20 +39,24 @@ export function Home() {
>
<div
class={`
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)] gap-4 px-3
max-md:grid-rows-[minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
`}
>
<HomeProjects projects={projects} scroll={scroll} />
<Show when={!mobile()}>
<HomeProjects projects={projects} scroll={scroll} />
</Show>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
<HomeUtilityNav
class="flex lg:hidden"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</ScrollView>
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
<HomeUtilityNav
class="flex"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
@media (max-width: 767px) {
[data-component="home-session-row-container"][data-project-name="true"],
[data-component="home-session-row-container"][data-project-name="true"] [data-component="home-session-row"],
[data-component="home-session-row-container"][data-project-name="true"] [data-slot="home-session-editor"],
[data-component="home-session-search-row"][data-project-name="true"] {
height: 48px;
padding-block: 0;
}
[data-project-name="true"] [data-slot="home-session-labels"] {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0;
text-align: start;
}
[data-project-name="true"] [data-slot="home-session-labels"] > * {
max-width: 100%;
flex: none;
font-size: 13px;
line-height: var(--line-height-compact);
}
}
+76 -54
View File
@@ -15,6 +15,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import "./view.css"
import {
HomeSessionStatusController,
homeSessionSearchKey,
@@ -96,10 +97,13 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.language.t("sidebar.project.recentSessions")}
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
onWheel={props.onWheel}
>
<HomeSessionSearch {...props} />
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
@@ -113,18 +117,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
<div
ref={props.onSetThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<div class="pt-1 md:pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
@@ -138,7 +142,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
@@ -150,7 +154,9 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
/>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
<div
class={`flex min-w-0 flex-col gap-px pt-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
@@ -235,7 +241,11 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
absolute flex flex-col overflow-hidden rounded-[12px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
`}
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
style={{
top: "-6px",
"inset-inline-start": "-6px",
width: "calc(100% + 12px)",
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
@@ -269,7 +279,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
>
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}>
{(record) => (
@@ -291,7 +301,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</Show>
<label
class={`
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
`}
@@ -375,6 +385,7 @@ function HomeSessionSearchResultRow(
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
data-project-name={!!showProjectName()}
role="option"
aria-selected={props.selected}
class={`
@@ -403,7 +414,7 @@ function HomeSessionSearchResultRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} search />
@@ -423,8 +434,8 @@ function HomeSessionGroupHeader(props: {
<div
ref={props.onSetRef}
class={`
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base pl-3 lg:top-[108px]
pointer-events-none sticky top-[68px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base ps-1.5 md:ps-3 md:top-[84px] lg:top-[108px]
`}
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
>
@@ -510,6 +521,7 @@ function HomeSessionRow(
return (
<div
data-component="home-session-row-container"
data-project-name={!!showProjectName()}
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
classList={{ group: !!showProjectName() }}
@@ -523,51 +535,56 @@ function HomeSessionRow(
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<div
data-slot="home-session-editor"
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
<div data-slot="home-session-labels" class="contents">
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</div>
}
>
@@ -578,7 +595,7 @@ function HomeSessionRow(
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
@@ -641,10 +658,12 @@ function HomeSessionRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
<div data-slot="home-session-labels" class="contents">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</button>
</Show>
<Menu
@@ -723,6 +742,7 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
return (
<span
data-component="home-session-title"
dir="auto"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
@@ -738,6 +758,8 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
return (
<span
data-component="home-session-project-name"
dir="auto"
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
>
@@ -779,7 +801,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class="flex h-7 min-w-0 items-center justify-between ps-1.5 pe-4 md:ps-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
+10 -1
View File
@@ -643,6 +643,7 @@ export const dict = {
"home.empty.description": "Get started by opening a local project",
"home.title": "Home",
"home.projects": "Projects",
"home.projects.all": "All projects",
"home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects",
@@ -659,6 +660,10 @@ export const dict = {
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"session.tab.session": "Session",
"session.tab.files": "Files",
"session.files.openTabs": "Open files",
"session.tab.usage": "Usage",
"session.view.select": "Session view",
"session.tab.review": "Review",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
@@ -697,6 +702,7 @@ export const dict = {
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.wrapLines": "Wrap lines",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noVcs.createGit.title": "Create a Git repository",
@@ -799,6 +805,7 @@ export const dict = {
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -976,7 +983,9 @@ export const dict = {
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"Place the title bar at the bottom of the screen on mobile",
"settings.general.row.mobileDiffWrap.description":
"Wrap long lines in mobile diffs instead of scrolling horizontally",
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
+43
View File
@@ -0,0 +1,43 @@
import { useLocation } from "@solidjs/router"
import { createEffect } from "solid-js"
const LAST_ROUTE_KEY = "opencode.pwa.last-route"
export function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && navigator.standalone === true)
)
}
export function restorePwaRoute() {
if (location.pathname !== "/" || location.search || location.hash) return
try {
const value = localStorage.getItem(LAST_ROUTE_KEY)
if (!value) return
const url = new URL(value, location.origin)
if (url.origin !== location.origin || url.searchParams.has("auth_token")) return
if (
url.pathname !== "/" &&
url.pathname !== "/new-session" &&
!/^\/server\/[^/]+\/session\/[^/]+$/.test(url.pathname)
)
return
history.replaceState(history.state, "", url.pathname + url.search + url.hash)
} catch {
// Storage may be unavailable; keep the launch URL in that case.
}
}
export function PwaRoutePersistence() {
const location = useLocation()
createEffect(() => {
const value = location.pathname + location.search + location.hash
try {
localStorage.setItem(LAST_ROUTE_KEY, value)
} catch {
// Navigation must still work when storage is unavailable or full.
}
})
return null
}
@@ -11,14 +11,7 @@ type SessionComposerRegionState = Pick<
export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
| "centered"
| "onResponseSubmit"
| "openParent"
| "setPromptRef"
| "setDockRef"
| "parentID"
| "child"
| "showComposer"
"centered" | "onResponseSubmit" | "openParent" | "setPromptRef" | "setDockRef" | "parentID" | "child" | "showComposer"
> & { state: SessionComposerRegionState }
export function SessionComposerRegion(props: {
@@ -68,10 +61,7 @@ export function SessionComposerRegion(props: {
"relative z-[70]": true,
}}
>
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
>
<Show when={controller.child()} fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}>
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
@@ -271,7 +271,7 @@ export function SessionContextTab() {
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div data-slot="session-usage-content" class="px-4 pt-4 pb-6 flex flex-col gap-6 md:px-6 md:pb-10 md:gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
@@ -1,4 +1,5 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createMemo, createUniqueId, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createQuery } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
@@ -34,6 +35,7 @@ export function SessionFileBrowserTab(props: {
onSelect: (path: string) => void
onSelectPermanent: (path: string) => void
filterRef?: (element: HTMLInputElement) => void
mobile?: boolean
}) {
const file = useFile()
const language = useLanguage()
@@ -42,8 +44,10 @@ export function SessionFileBrowserTab(props: {
const serverSDK = useServerSDK()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const [store, setStore] = createStore({ filter: "", explicitHighlight: undefined as string | undefined })
const filter = () => store.filter
const setFilter = (value: string) => setStore("filter", value)
const setExplicitHighlight = (value: string) => setStore("explicitHighlight", value)
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
@@ -61,7 +65,7 @@ export function SessionFileBrowserTab(props: {
const highlighted = createMemo(() => {
const values = files()
if (values.length === 0) return undefined
const explicit = explicitHighlight()
const explicit = store.explicitHighlight
if (explicit && values.includes(explicit)) return explicit
return values[0]
})
@@ -105,13 +109,13 @@ export function SessionFileBrowserTab(props: {
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterAutofocus={props.placeholder && !props.mobile}
filterRef={(element) => props.filterRef?.(element)}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
onWidthChange={props.mobile ? undefined : props.state.resizeSidebar}
>
<Show
when={query()}
@@ -119,6 +123,7 @@ export function SessionFileBrowserTab(props: {
<FileTreeV2
active={props.active}
kinds={props.kinds}
draggable={!props.mobile}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
@@ -0,0 +1,35 @@
[data-slot="session-mobile-files"]
[data-slot="session-mobile-files-header"]
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"]
[data-slot="tabs-v2-list"] {
position: static;
&::before {
inset-inline-start: 0;
width: 100%;
}
}
[data-slot="session-mobile-files"] [data-component="line-comment-v2"] {
max-width: none;
}
[data-slot="session-mobile-files"][data-browsing="true"] {
[data-component="session-review-v2-sidebar-root"] {
width: 100%;
}
[data-slot="session-review-v2-sidebar"] {
width: 100% !important;
border-inline-end: 0;
}
[data-slot="session-review-v2-preview"] {
display: none;
}
}
[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] {
width: 32px;
height: 36px;
}
@@ -0,0 +1,100 @@
import { Button } from "@opencode-ai/ui/button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
import { useFile } from "@/workspaces/files/model"
import { SessionFileBrowserTab } from "./session-file-browser-tab"
import type { Kind } from "./file-tree-v2"
import "./session-mobile-files.css"
export function SessionMobileFiles() {
const file = useFile()
const language = useLanguage()
const layout = useSessionLayout()
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab: file.tab,
})
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
const browsing = () => store.browsing || !tabs.activeFileTab()
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
const kinds = new Map<string, Kind>()
const open = (path: string) => {
layout.tabs().open(file.tab(path))
void file.load(path)
setStore("browsing", false)
}
return (
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
<Button
size="small"
variant="ghost"
class="shrink-0 mx-2"
onClick={() => setStore("browsing", true)}
aria-pressed={browsing()}
>
{language.t("session.files.all")}
</Button>
<Tabs
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
onChange={(tab) => {
// Kobalte falls back to a file tab when the browse view has no trigger.
if (browsing()) return
const path = file.pathFromTab(tab)
if (path) open(path)
}}
variant="line"
class="min-w-0 flex-1 !h-auto"
>
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
<For each={tabs.openedTabs()}>
{(tab) => (
<Tabs.Trigger
value={tab}
onClick={() => open(file.pathFromTab(tab)!)}
class="shrink-0 max-w-48"
classes={{ button: "min-w-0" }}
closeButton={
<Tabs.CloseButton
aria-label={language.t("common.closeTab")}
onClick={() => layout.tabs().close(tab)}
/>
}
>
<span dir="ltr" class="truncate">
{getFilename(file.pathFromTab(tab) ?? tab)}
</span>
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
</div>
<div class="min-h-0 flex-1">
<SessionFileBrowserTab
mobile
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={browsing()}
active={active()}
kinds={kinds}
state={{
sidebarOpened: browsing,
sidebarWidth: () => 240,
sidebarTransition: () => false,
resizeSidebar: () => undefined,
toggleSidebar: () => setStore("browsing", !browsing()),
}}
onSelect={open}
onSelectPermanent={open}
/>
</div>
</div>
)
}
@@ -19,7 +19,10 @@ export function SessionHeader() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> } : undefined,
status:
isDesktop() && status()
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
: undefined,
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
+22
View File
@@ -36,6 +36,28 @@ describe("createOpenReviewFile", () => {
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
})
test("selects immediately and does not steal focus when loading finishes", async () => {
const loading = Promise.withResolvers<void>()
const state = { active: "file://previous.ts", opened: [] as string[] }
const openReviewFile = createOpenReviewFile({
showAllFiles: () => undefined,
tabForPath: (path) => `file://${path}`,
openTab: (tab) => state.opened.push(tab),
setActive: (tab) => {
state.active = tab
},
loadFile: () => loading.promise,
})
openReviewFile("requested.ts")
expect(state.opened).toEqual(["file://requested.ts"])
expect(state.active).toBe("file://requested.ts")
state.active = "file://previous.ts"
loading.resolve()
await loading.promise
expect(state.active).toBe("file://previous.ts")
})
})
describe("createOpenSessionFileTab", () => {
+4 -8
View File
@@ -124,14 +124,10 @@ export const createOpenReviewFile = (input: {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) void maybePromise.then(open)
else open()
input.loadFile(path)
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
})
}
}
+5 -3
View File
@@ -38,7 +38,7 @@ export function createSessionReview(input: {
const location = useWorkspaceLocation()
const server = useServerSDK()
const [state, setState] = createStore({
mobileTab: "session" as "session" | "changes",
mobileTab: "session" as "session" | "changes" | "files" | "usage",
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
@@ -66,7 +66,9 @@ export function createSessionReview(input: {
}
return list
})
const mobileChanges = createMemo(() => !input.session.isDesktop() && state.mobileTab === "changes")
const mobileChanges = createMemo(
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
@@ -407,7 +409,7 @@ export function createSessionReview(input: {
loadDiff,
mobile: {
changes: mobileChanges,
setTab: (tab: "session" | "changes") => setState("mobileTab", tab),
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
tab: () => state.mobileTab,
},
mode,
@@ -23,6 +23,9 @@ export interface SessionReviewTabProps {
diffs: ReviewDiff[]
view: ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
changeSummary?: boolean
overflow?: "wrap" | "scroll"
disableLineNumbers?: boolean
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
@@ -122,6 +125,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
createEffect(() => {
props.diffs.length
props.diffStyle
props.overflow
if (!layout.ready()) return
queueRestore()
})
@@ -156,6 +160,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
}}
diffs={props.diffs}
diffStyle={props.diffStyle}
changeSummary={props.changeSummary}
overflow={props.overflow}
disableLineNumbers={props.disableLineNumbers}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
focusedFile={props.focusedFile}
+134 -40
View File
@@ -2,51 +2,137 @@ import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Match, Show, Suspense, Switch } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
export function SessionMobileTabs(props: { review: SessionReviewModel; compact?: boolean; bottom?: boolean }) {
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export function SessionMobileViewTabs(props: {
current: "session" | "changes" | "files" | "usage" | "terminal"
onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void
details?: (close: () => void) => JSX.Element
onDetailsOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: undefined as "status" | "details" | undefined,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
let trigger: HTMLButtonElement | undefined
return (
<Tabs value={props.review.mobile.tab()} class="h-auto">
<Tabs.List
classList={{
"!h-9": props.compact,
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": props.bottom,
}}
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<For each={["session", "changes", "files", "terminal"] as const}>
{(view) => (
<Tabs.Trigger
value={view}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
onClick={() => props.onSelect(view)}
>
{view === "session"
? language.t("session.tab.session")
: view === "changes"
? language.plural("session.review.change", 0)
: view === "files"
? language.t("session.tab.files")
: language.t("terminal.title")}
</Tabs.Trigger>
)}
</For>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Tabs.Trigger
value="session"
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
props.bottom,
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
onClick={() => props.review.mobile.setTab("session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
classList={{
"!w-1/2 !max-w-none !border-r-0": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
props.bottom,
}}
onClick={() => props.review.mobile.setTab("changes")}
>
{props.review.hasChanges()
? language.t("session.review.filesChanged", { count: props.review.count() })
: language.plural("session.review.change", 0)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.current === "usage" || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
title={language.t("session.summary.title")}
open={store.details}
onOpenChange={(open) => setStore("details", open)}
returnFocus={() => trigger}
>
{props.details?.(() => setStore("details", false))}
</MobilePanelDrawer>
</Suspense>
</Show>
</div>
)
}
@@ -88,14 +174,22 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
}
function ReviewContent(props: { review: SessionReviewModel }) {
const settings = useSettings()
return (
<Show when={!props.review.deferRender()}>
<SessionReviewTab
title={<ReviewTitle review={props.review} />}
empty={<ReviewEmpty review={props.review} loadingClass="px-4 py-4 text-text-weak" />}
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
diffs={props.review.diffs()}
view={props.review.view()}
diffStyle="unified"
changeSummary
disableLineNumbers={false}
overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"}
onViewFile={(file) => {
props.review.openFile(file)
props.review.mobile.setTab("files")
}}
onScrollRef={props.review.setScroll}
focusedFile={props.review.activeFile()}
onLineComment={props.review.comments.add}
@@ -106,11 +200,11 @@ function ReviewContent(props: { review: SessionReviewModel }) {
comments={props.review.comments.all()}
focusedComment={props.review.comments.focus()}
onFocusedCommentChange={props.review.comments.setFocus}
onViewFile={props.review.openFile}
classes={{
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0",
header:
"!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']",
container: "!px-0",
}}
/>
</Show>
+1 -1
View File
@@ -154,7 +154,7 @@ function PendingSessionState(props: { sessionID: string }) {
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
+112 -19
View File
@@ -1,11 +1,23 @@
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
import {
ErrorBoundary,
Show,
Match,
Switch,
Suspense,
lazy,
createMemo,
createEffect,
createComputed,
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
@@ -14,15 +26,25 @@ import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
import { createSessionScreenLayout } from "./screen-layout"
import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
return { default: SessionMobileFiles }
})
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const settings = useSettings()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
@@ -34,6 +56,8 @@ export function SessionScreen(props: { session: SessionModel }) {
sideRegionPresent: false,
sideReviewPresent: false,
sideTerminalPresent: false,
mobileTerminalCached: false,
mobileMoveDismissed: false,
})
const [elements, setElements] = createStore<{
side?: HTMLDivElement
@@ -41,7 +65,7 @@ export function SessionScreen(props: { session: SessionModel }) {
}>({})
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
@@ -67,6 +91,7 @@ export function SessionScreen(props: { session: SessionModel }) {
createComputed((prev) => {
const key = session.identity.sessionKey()
if (key !== prev) {
setStore("mobileMoveDismissed", false)
setStore("deferRender", true)
const owner = session.ownership.capture()
requestAnimationFrame(() => {
@@ -76,6 +101,11 @@ export function SessionScreen(props: { session: SessionModel }) {
return key
})
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
createEffect(() => {
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
})
const composer = createActiveSessionRegion({
session,
screen,
@@ -84,36 +114,102 @@ export function SessionScreen(props: { session: SessionModel }) {
useUsageExceededDialogs()
const mobileTabsBottom = createMemo(() => !isDesktop() && settings.general.mobileTitlebarPosition() === "bottom")
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={session.identity.params.id} />
}
const mobileTabs = () => (
<Show when={session.identity.sessionKey()} keyed>
{(_key) => (
<SessionMobileViewTabs
current={mobileView()}
onDetailsOpenChange={review.details.setOpen}
details={
!session.data.isChild() && detailsProject()
? (close) => (
<Show when={detailsProject()}>
{(project) => (
<SessionSummaryPanel
mobile
project={project()}
directory={session.workspace.directory()}
local={!session.workspace.current()}
branch={
session.shared.data.location.vcs.info({ directory: session.workspace.directory() })?.branch
.current
}
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs === "git" ? review.details.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
onReview={() => {
close()
review.mobile.setTab("changes")
session.layout.view().terminal.close()
}}
backgroundTasks={composer.region.state.background.tasks()}
/>
)}
</Show>
)
: undefined
}
onSelect={(view) => {
if (view === "terminal") {
session.layout.view().terminal.open()
return
}
review.mobile.setTab(view)
session.layout.view().terminal.close()
}}
/>
)}
</Show>
)
const sessionPanelContent = () => (
<>
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
<SessionMobileTabs review={review} compact />
</Show>
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
{(error) => {
throw error()
}}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<div class="relative flex-1 min-h-0 overflow-hidden">
<Show when={!isDesktop() && store.mobileTerminalCached}>
<div class="absolute inset-0" classList={{ invisible: mobileView() !== "terminal" }}>
<TerminalPanel fill embedded present contentHeight="100%" />
</div>
</Show>
<Switch>
<Match when={!isDesktop() && mobileView() === "terminal"}>
<></>
</Match>
<Match when={!isDesktop() && mobileView() === "usage"}>
<SessionContextTab />
</Match>
<Match when={!isDesktop() && mobileView() === "files"}>
<Suspense>
<SessionMobileFiles />
</Suspense>
</Match>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
</Match>
<Match when={session.identity.params.id}>
<Show when={!messagesReady()}>
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
@@ -143,21 +239,18 @@ export function SessionScreen(props: { session: SessionModel }) {
</Switch>
</div>
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
)}
</Show>
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
<SessionMobileTabs review={review} compact bottom />
</Show>
</>
)
return (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
@@ -300,7 +393,7 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
</div>
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
<Show when={isDesktop() && (bottomTerminalPresence.present() || store.bottomTerminalCached)}>
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
+2 -2
View File
@@ -3,8 +3,8 @@ import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div
class="relative flex size-full flex-col overflow-hidden"
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
class="relative flex size-full flex-col"
classList={{ "px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]": props.padded }}
>
{props.children}
</div>
+9 -1
View File
@@ -38,7 +38,14 @@ type CachedTerminalSurface = {
}
export function TerminalPanel(
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
props: {
stacked?: boolean
fill?: boolean
framed?: boolean
present?: boolean
contentHeight?: string
embedded?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
@@ -223,6 +230,7 @@ export function TerminalPanel(
opened={opened()}
present={present()}
framed={props.framed}
embedded={props.embedded}
desktop={isDesktop()}
stacked={stacked()}
height={panelHeight()}
@@ -7,6 +7,7 @@ export function TerminalSurface(
opened: boolean
present?: boolean
framed?: boolean
embedded?: boolean
desktop: boolean
stacked: boolean
height: string
@@ -26,7 +27,7 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -37,11 +38,14 @@ export function TerminalSurface(
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
}}
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
>
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
<div
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
onPointerDown={props.onResizeStart}
>
<ResizeHandle
class="-top-1"
direction="vertical"
@@ -57,7 +61,7 @@ export function TerminalSurface(
data-slot="terminal-panel-content"
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
classList={{
"border-t border-border-weak-base": props.opened && !props.desktop,
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
"pointer-events-none": !props.opened,
}}
style={{ height: props.contentHeight }}
@@ -69,7 +69,7 @@ export function BackgroundMoveHint(props: { keybind?: string[] }) {
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const taskType = (task: BackgroundTask) => {
@@ -81,7 +81,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
return (
<Popover
open={open()}
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={setOpen}
>
@@ -126,6 +126,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
mobile?: boolean
eligible: boolean
sessionID: string
project: Project
@@ -150,9 +151,17 @@ function WorkspaceMoveAction(props: {
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={inline() ? "bottom-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={inline() ? 4 : -22}
contentClass={inline() ? undefined : "relative top-3.5"}
placement={
props.mobile
? "top-end"
: inline()
? "bottom-end"
: language.direction() === "rtl"
? "right-start"
: "left-start"
}
gutter={props.mobile || inline() ? 4 : -22}
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
@@ -181,7 +190,8 @@ function WorkspaceMoveAction(props: {
)
}
function SessionSummaryPanel(props: {
export function SessionSummaryPanel(props: {
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
@@ -207,7 +217,7 @@ function SessionSummaryPanel(props: {
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div data-component="session-summary-panel" class="w-[280px]">
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
{props.avatar ?? (
@@ -217,19 +227,23 @@ function SessionSummaryPanel(props: {
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{displayName(props.project)}
</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={-22}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
@@ -252,7 +266,9 @@ function SessionSummaryPanel(props: {
</span>
}
>
<span class="min-w-0 truncate">{branch()}</span>
<span dir="auto" class="min-w-0 truncate">
{branch()}
</span>
</Show>
</div>
<button
@@ -272,12 +288,13 @@ function SessionSummaryPanel(props: {
</Show>
</button>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} />
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
mobile={props.mobile}
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
@@ -291,6 +308,7 @@ function SessionSummaryPanel(props: {
}
type MessageTimelineProps = {
hideHeader?: boolean
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
@@ -393,7 +411,7 @@ function MessageTimelineView(
}),
)
const turnPadding = () => "px-4 md:px-5"
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
const showHeader = createMemo(() => !props.hideHeader && (props.data.showHeader() || workspaceSession()))
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
@@ -556,198 +574,200 @@ function MessageTimelineView(
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center min-w-0 flex-1 w-full">
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
{projectAvatar()}
</Show>
</span>
}
>
<Tooltip
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": !showProjectIcon(),
}}
>
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
{projectAvatar()}
</Show>
</span>
</Tooltip>
</Show>
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={childTitle() || title.editing}>
<Show when={!props.hideHeader}>
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center min-w-0 flex-1 w-full">
<Show
when={title.editing}
when={workspaceSession()}
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
onClick={openTitleEditor}
>
{childTitle()}
</h1>
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
{projectAvatar()}
</Show>
</span>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
style={{
"--inline-input-shadow": "none",
"text-align": "start",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => void saveTitleEditor()}
/>
</Show>
</Show>
</div>
</div>
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
)}
</Show>
<Show when={!parentID()}>
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
<Tooltip
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
}}
<span
tabIndex={0}
aria-label={sessionDirectory()}
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": !showProjectIcon(),
}}
>
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
{projectAvatar()}
</Show>
</span>
</Tooltip>
</Show>
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
onClick={openTitleEditor}
>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
style={{
"--inline-input-shadow": "none",
"text-align": "start",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => void saveTitleEditor()}
/>
</Show>
</Show>
</div>
)}
</Show>
</div>
</SessionTitleHeader>
</div>
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
)}
</Show>
<Show when={!parentID()}>
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
}}
>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
)}
</Show>
</div>
</SessionTitleHeader>
</Show>
}
/>
)
@@ -22,6 +22,7 @@ import {
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
@@ -73,6 +74,8 @@ type ViewProps = {
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
@@ -186,7 +189,7 @@ export function createTimelineVirtualizer(input: Input) {
},
scrollEndThreshold: 80,
get scrollMargin() {
return input.showHeader() ? 64 : 0
return topOffset()
},
paddingEnd: 64,
get rangeExtractor() {
@@ -446,7 +449,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-key={rowProps.rowKey}
style={{
position: "absolute",
top: `${item().start - (input.showHeader() ? 64 : 0)}px`,
top: `${item().start - topOffset()}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
@@ -516,7 +519,9 @@ export function createTimelineVirtualizer(input: Input) {
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
>
<Show when={input.showHeader()}>{props.header}</Show>
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
{props.header}
</Show>
<div
data-timeline-virtual-content
ref={(element) => {
@@ -362,6 +362,22 @@ export const SettingsGeneral: Component<{
<ReasoningModeSetting />
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
>
<div data-action="settings-mobile-diff-wrap">
<Switch
aria-label={language.t("session.review.wrapLines")}
checked={settings.general.mobileDiffWrap()}
onChange={settings.general.setMobileDiffWrap}
hideLabel
>
{language.t("session.review.wrapLines")}
</Switch>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
+6
View File
@@ -41,6 +41,7 @@ export interface Settings {
editToolPartsExpanded: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
mobileDiffWrap: boolean
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
}
@@ -130,6 +131,7 @@ const defaultSettings: Settings = {
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
},
@@ -268,6 +270,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value)
},
mobileDiffWrap: withFallback(() => store.general?.mobileDiffWrap, defaultSettings.general.mobileDiffWrap),
setMobileDiffWrap(value: boolean) {
setStore("general", "mobileDiffWrap", value)
},
terminalPlacement: withFallback(
() => store.general?.terminalPlacement,
defaultSettings.general.terminalPlacement,
+80 -38
View File
@@ -24,6 +24,13 @@
min-width: 0;
}
@media (max-width: 767px) {
.settings-screen {
--settings-mobile-inner-inset: 8px;
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
}
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
@@ -49,6 +56,7 @@
.settings-screen .settings-tab-body {
padding-inline: 0;
padding-bottom: var(--settings-bottom-inset, 0px);
}
.settings-nav {
@@ -59,6 +67,26 @@
gap: 16px;
}
.settings-mobile-nav {
display: none;
}
.settings-mobile-menu-trigger {
min-width: 0;
}
.settings-mobile-menu-trigger > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-mobile-menu[data-component="menu-v2-content"] {
max-width: calc(100vw - 32px);
max-height: var(--kb-popper-content-available-height);
overflow-y: auto;
}
.settings-nav-footer {
display: flex;
flex-shrink: 0;
@@ -291,36 +319,6 @@
background-color: var(--v2-background-bg-layer-01);
}
@media (max-width: 639px) {
.settings-screen .settings-nav {
width: 100%;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
}
.settings-screen .settings-tab-header {
padding-top: 24px;
}
.settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
}
}
@container settings-screen (min-width: 800px) and (max-width: 1047px) {
.settings-screen > .settings {
padding-inline: 24px;
@@ -337,25 +335,60 @@
}
@container settings-screen (max-width: 799px) {
.settings-screen .settings-nav {
width: 100%;
.settings-screen > .settings[data-component="tabs-v2"] {
flex-direction: column;
}
.settings-mobile-nav {
position: relative;
z-index: 11;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px var(--settings-mobile-inner-inset, 16px);
border-bottom: 0.5px solid var(--v2-border-border-muted);
background: var(--v2-background-bg-deep);
}
.settings-mobile-nav::after {
content: "";
position: absolute;
inset-inline: 0;
inset-block-start: 100%;
height: 1px;
background: var(--v2-background-bg-deep);
pointer-events: none;
}
.settings-mobile-nav .settings-back {
flex-shrink: 0;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
min-height: 0;
max-width: none;
}
.settings-screen .settings-tab-header,
.settings-screen .settings-tab-body {
padding-inline: var(--settings-mobile-inner-inset, 16px);
}
.settings-screen .settings-tab-header {
padding-top: 24px;
padding-top: 12px;
}
.settings-screen .settings-tab-title,
.settings-screen .settings-section-title {
line-height: var(--line-height-base);
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
display: none;
}
}
@@ -636,6 +669,15 @@
gap: 0;
}
.settings-models [data-component="settings-row"] {
flex-wrap: nowrap;
}
.settings-models [data-slot="settings-row-control"] {
width: auto;
flex-shrink: 0;
}
.settings-models [data-slot="settings-row-title"] {
min-width: 0;
overflow: hidden;
+85 -52
View File
@@ -1,6 +1,18 @@
import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js"
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Show,
onCleanup,
onMount,
startTransition,
} from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsGeneral } from "./general/general"
@@ -23,6 +35,25 @@ import { useCommand } from "@/shell/commands/command"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
const sections = [
[
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
],
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
],
] as const
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
@@ -103,6 +134,45 @@ export const SettingsScreen: Component<{
onChange={(value) => void startTransition(() => setTab(value))}
class="settings"
>
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
<For each={sections}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem value={section.value} closeOnSelect>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
@@ -110,57 +180,20 @@ export const SettingsScreen: Component<{
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.preferences")}
</Tabs.Trigger>
<Tabs.Trigger value="appearance">
<Icon name="appearance" />
{language.t("settings.general.section.appearance")}
</Tabs.Trigger>
<Tabs.Trigger value="notifications">
<Icon name="notifications" />
{language.t("settings.tab.notifications")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
</div>
{/* Group 2: Environment & Workspaces */}
<div class="flex flex-col gap-1 w-full">
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="projects">
<Icon name="folder" />
{language.t("settings.tab.projects")}
</Tabs.Trigger>
<Tabs.Trigger value="workspaces">
<Icon name="workspace-isolated" />
{language.t("settings.tab.workspaces")}
</Tabs.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
<div class="flex flex-col gap-1 w-full">
<Tabs.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" />
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
<For each={sections}>
{(group) => (
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger value={section.value}>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
<div class="settings-nav-footer">
@@ -0,0 +1,44 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
title: string
open: boolean
onOpenChange: (open: boolean) => void
returnFocus?: () => HTMLElement | undefined
}>,
) {
const language = useLanguage()
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function AppRoutes() {
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
+19 -9
View File
@@ -23,6 +23,7 @@ export default function Layout(props: ParentProps) {
tabsMount: undefined as HTMLElement | undefined,
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
const update: TitlebarUpdate = {
get version() {
@@ -41,15 +42,13 @@ export default function Layout(props: ParentProps) {
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
}}
>
<Titlebar
@@ -66,8 +65,11 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
style={{ width: `${state.tabsWidth}px` }}
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
}}
>
<ResizeHandle
class="-end-2"
@@ -80,7 +82,15 @@ export default function Layout(props: ParentProps) {
</aside>
</Show>
{/* Size containment collapses percentage-height descendants in WebKit. */}
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content">
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
+8 -2
View File
@@ -20,7 +20,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
)
}
export function StatusPopoverBody(props: { shown: boolean }) {
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
const data = useData()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -42,7 +42,13 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<div
class="flex items-center gap-1 rounded-xl"
classList={{
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
"w-full min-w-0": props.embedded,
}}
>
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
@@ -0,0 +1,145 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
align-items: center;
justify-content: center;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -0,0 +1,35 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
return { default: StatusPopoverBody }
})
export function StatusDrawer(props: {
open: boolean
onOpenChange: (open: boolean) => void
returnFocus?: () => HTMLElement | undefined
}) {
const language = useLanguage()
return (
<MobilePanelDrawer
title={language.t("status.popover.trigger")}
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
>
<Suspense
fallback={
<div data-slot="mobile-status-loading" role="status">
{language.t("common.loading")}
</div>
}
>
<Body shown={props.open} embedded />
</Suspense>
</MobilePanelDrawer>
)
}
+45 -3
View File
@@ -57,9 +57,10 @@
margin-block: auto;
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
[data-titlebar-tab]:not(:where([data-slot="mobile-tabs-drawer"] *)):is(
:hover,
:has(> [data-slot="tab-link"]:focus-visible)
):not([data-state="pressed"]):not([data-dragging="true"]):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
@@ -183,6 +184,36 @@
display: none;
}
@media (hover: none) {
[data-titlebar-tab] [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
--tab-title-fade-direction: to right;
-webkit-mask-image: linear-gradient(
var(--tab-title-fade-direction),
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
var(--tab-title-fade-direction),
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab]:dir(rtl) [data-slot="tab-link"] {
--tab-title-fade-direction: to left;
}
}
@container (max-width: 64px) {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
@@ -190,6 +221,17 @@
padding-inline: 0;
}
@media (hover: none) {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab][data-title-overflow="true"]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(
rtl
)
[data-slot="tab-link"] {
-webkit-mask-image: none;
mask-image: none;
}
}
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
display: none;
}
@@ -1,6 +1,7 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { createSignal, Show, type JSXElement } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { createMediaQuery } from "@solid-primitives/media"
import "./tab-popover.css"
// Initial hover delay before the preview appears, per design.
@@ -28,6 +29,7 @@ export function TabPreviewPopover(props: {
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const mobile = createMediaQuery("(max-width: 767px)")
let triggerEl: HTMLDivElement | undefined
// When opened during a rapid tab-hopping streak, this preview appears and
// disappears instantly (no repeated enter/exit animation) — only the first,
@@ -46,7 +48,7 @@ export function TabPreviewPopover(props: {
return (
<HoverCard
open={props.open}
open={props.open && !mobile()}
onOpenChange={handleOpenChange}
openDelay={resolveOpenDelay()}
closeDelay={CLOSE_DELAY}
@@ -2,6 +2,155 @@
user-select: none;
}
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
background:
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
var(--v2-background-bg-layer-02);
}
[data-action="mobile-tabs-home"][data-state="pressed"] {
background:
linear-gradient(var(--v2-overlay-simple-overlay-pressed), var(--v2-overlay-simple-overlay-pressed)),
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-tabs-drawer-list"] {
display: flex;
min-height: 0;
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs-scroll"] {
min-height: 0;
overscroll-behavior: contain;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] {
min-height: 44px;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-close"] {
inset-inline-end: 0;
width: 44px;
height: 44px;
}
[data-slot="mobile-tabs-drawer"] [data-slot="tab-close"] button {
width: 44px;
height: 44px;
opacity: 1;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-link"] {
padding-inline-end: 38px;
-webkit-mask-image: none;
mask-image: none;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-title"] {
color: var(--v2-text-text-base);
font-weight: 530;
}
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-project"] {
color: var(--v2-text-text-faint);
font-weight: 440;
}
[data-slot="titlebar-tab-item"] a {
outline: none;
}
+269 -88
View File
@@ -25,6 +25,11 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import Drawer from "@corvu/drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
@@ -47,6 +52,7 @@ export function Titlebar(props: {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const openSettings = useSettingsDialog()
const navigate = useNavigate()
const location = useLocation()
const mobile = createMediaQuery("(max-width: 767px)")
@@ -138,6 +144,14 @@ export function Titlebar(props: {
"order-last": bottom(),
}}
style={{
height:
platform.platform === "web"
? bottom()
? "calc(28px + max(8px, env(safe-area-inset-bottom, 0px)))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: undefined,
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : "0px",
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
@@ -336,116 +350,283 @@ export function Titlebar(props: {
].filter((v) => v !== undefined)
})
const [mobileTabs, setMobileTabs] = createStore({ open: false, settings: false })
const currentProject = createMemo(() => {
const tab = currentTab()
const value = session()
if (!tab || !value) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
})
const currentTitle = () => {
const tab = currentTab()
if (!tab) return language.t("home.title")
if (tab.type === "draft") return language.t("command.session.new")
const value = session()
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
}
createEffect(() => {
path()
mobile()
setMobileTabs("open", false)
})
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
classList={{
"pt-2": !bottom() && !windows(),
"pb-2": bottom(),
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
}}
>
<ChannelIndicator debugTools={props.debugTools} />
<Show when={!mobile()}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
<Show when={!mobile()}>
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
</Show>
<Show
when={props.verticalTabs}
when={!mobile()}
fallback={
<>
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
if (present || !mobileTabs.settings) return
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<Drawer.Trigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
</>
}
>
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={currentTab()} fallback={<Icon name="grid-plus" class="shrink-0" />}>
{(tab) => (
<span
data-slot="project-avatar-slot"
class="flex size-4 shrink-0 items-center justify-center"
>
<Show
when={session()}
fallback={
tab().type === "draft" ? (
<Icon name="edit" />
) : (
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
)
}
>
{(value) => (
<SessionTabAvatar
project={currentProject()}
directory={value().location.directory}
sessionId={value().id}
server={tab().server}
revealProjectOnHover={false}
/>
)}
</Show>
</span>
)}
</Show>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab) => {
tabs.select(tab)
setMobileTabs("open", false)
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
openNewTab()
setMobileTabs("open", false)
}}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
</Portal>
)}
</Show>
)}
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
aria-current={layout.route().type === "home" ? "page" : undefined}
class="flex h-7 w-full items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none"
onClick={() => {
if (layout.route().type !== "home") toggleHome()
setMobileTabs("open", false)
}}
>
<Icon name="grid-plus" />
{language.t("home.title")}
</button>
<div class="flex items-center gap-1">
<button
type="button"
data-action="mobile-tabs-settings"
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => setMobileTabs({ open: false, settings: true })}
>
<Icon name="settings-gear" size="small" />
{language.t("sidebar.settings")}
</button>
<button
type="button"
data-action="mobile-tabs-help"
class="flex h-7 shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
setMobileTabs("open", false)
platform.openExternal("https://opencode.ai/desktop-feedback")
}}
>
<Icon name="help" size="small" />
{language.t("sidebar.help")}
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
when={props.verticalTabs}
fallback={
<>
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
</>
}
>
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
</Portal>
)}
</Show>
)}
</Show>
</Show>
<Show when={!mobile()}>
<div class="flex-1" />
</Show>
<div class="flex-1" />
<TitlebarRight state={rightState()} />
</div>
)
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createComponent, render } from "solid-js/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "../src/runtime/platform/pwa"
const key = "opencode.pwa.last-route"
const originalUrl = window.location.href
beforeEach(() => {
window.location.href = "http://localhost/"
})
afterEach(() => {
localStorage.removeItem(key)
window.location.href = originalUrl
})
test("normal browser windows are not standalone", () => {
expect(isStandalone()).toBe(false)
})
test("restores the last PWA route including query and hash without adding history", () => {
window.history.replaceState({ retained: true }, "", "http://localhost/")
const length = window.history.length
localStorage.setItem(key, "/server/local/session/session-1?view=files#file")
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
"/server/local/session/session-1?view=files#file",
)
expect(window.history.length).toBe(length)
expect(window.history.state).toEqual({ retained: true })
})
test("preserves explicit launch routes, queries, and hashes", () => {
localStorage.setItem(key, "/server/local/session/saved")
for (const route of ["/server/local/session/linked", "/new-session?draftId=123", "/?launch=1", "/#launch"]) {
window.history.replaceState(null, "", `http://localhost${route}`)
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(route)
}
})
test("ignores missing, invalid, external, and auth-bearing saved routes", () => {
window.history.replaceState(null, "", "http://localhost/")
restorePwaRoute()
expect(window.location.pathname).toBe("/")
for (const value of [
"/removed-route",
"https://example.com/new-session",
"//example.com/new-session",
"http://[",
"/new-session?auth_token=secret",
]) {
localStorage.setItem(key, value)
restorePwaRoute()
expect(window.location.href).toBe("http://localhost/")
}
})
test("persists router navigation including returning home", async () => {
const host = document.createElement("div")
const history = createMemoryHistory()
history.set({ value: "/new-session?draftId=123", replace: true, scroll: false })
const dispose = render(() => createComponent(MemoryRouter, { history, root: PwaRoutePersistence }), host)
try {
expect(localStorage.getItem(key)).toBe("/new-session?draftId=123")
history.set({ value: "/server/local/session/next#file", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/server/local/session/next#file")
history.set({ value: "/", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/")
} finally {
dispose()
}
})
+1
View File
@@ -59,6 +59,7 @@ test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
const html = new TextDecoder().decode(await read("/index.html"))
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
expect(actual.icons.every((icon) => icon.purpose === "maskable")).toBe(true)
expect(actual).toEqual({
...manifest,
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
+15
View File
@@ -1,6 +1,7 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -56,6 +57,20 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -0,0 +1,38 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
+7 -5
View File
@@ -17,6 +17,7 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
@@ -98,12 +99,13 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
],
]),
}),
),
Effect.provide(
Observability.layer({
+2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+6 -5
View File
@@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
],
]),
}),
),
Effect.provide(NodeServices.layer),
)
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
function parseReleaseVersion(input: string) {
export function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+12 -8
View File
@@ -5,19 +5,21 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -110,7 +112,9 @@ export const layer = Layer.effect(
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -138,7 +142,7 @@ export const layer = Layer.effect(
}
return yield* run(commands[method], "5 minutes")
}),
)
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
@@ -173,7 +177,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check })
return Service.of({ check, method, latest, upgrade })
}),
)
+16 -2
View File
@@ -486,7 +486,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
})
}),
)
@@ -494,7 +501,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
+36
View File
@@ -0,0 +1,36 @@
import { NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Command } from "effect/unstable/cli"
import { Commands } from "../../src/commands/commands"
import upgrade from "../../src/commands/handlers/upgrade"
import { Updater } from "../../src/services/updater"
const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`)
await Effect.runPromise(
Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
method: () =>
Effect.sync(() => {
record("method")
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
}),
latest: () =>
Effect.suspend(() => {
record("latest")
return process.env.UPGRADE_TEST_LATEST_ERROR
? Effect.fail(new Error("Update check failed"))
: Effect.succeed("0.0.0-beta-new")
}),
upgrade: (method, version) =>
Effect.suspend(() => {
record({ method, version })
return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void
}),
}),
Effect.provide(NodeServices.layer),
),
)
+2
View File
@@ -9,6 +9,7 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -145,6 +146,7 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
+227
View File
@@ -0,0 +1,227 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
import { Updater } from "../src/services/updater"
import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeServices.layer)
declare const OPENCODE_CLI_NAME: string | undefined
function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "bin"),
log: path.join(root, "log"),
repos: path.join(root, "repos"),
})
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
...spawner,
run: (command) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command")
commands.push([command.command, ...command.args])
const result = respond(command)
if (result.error) return Effect.fail(result.error)
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
...result,
})
}),
runStream: () => Stream.die("Unexpected streaming install command"),
}),
),
)
return { updater, commands, global, fs }
})
}
const installs = [
{ method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] },
{
method: "pnpm",
command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
it.live(`${method} installs the explicit V2 package version without a leading v`, () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
expect(test.commands).toEqual([[...command]])
}),
)
})
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
expect(command.command).toBe("bun")
expect(existsSync(command.args[4])).toBe(true)
return { exitCode, stderr: Buffer.from("bun install failed") }
})
const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const cache = test.commands[0]?.[5]
expect(cache).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"],
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(exitCode === 0 ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe("bun install failed")
}),
)
})
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
const installer = command.command === "curl" ? command.args[2] : command.args[0]
expect(existsSync(path.dirname(installer))).toBe(true)
return {
exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0,
stderr: Buffer.from(`${failure} failed`),
}
})
const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const installer = test.commands[0]?.[3]
expect(installer).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]),
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(failure === "success" ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`)
}),
)
})
it.live("invalid version targets never execute a command or create a cache", () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* Effect.forEach(Updater.methods, (method) =>
Effect.forEach(
["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"],
(version) =>
Effect.gen(function* () {
const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip)
expect(error.message).toBe(`Invalid version: ${version}`)
}),
),
)
expect(test.commands).toEqual([])
expect(yield* test.fs.exists(test.global.cache)).toBe(false)
}),
)
it.live("install failures expose stderr and process errors do not report success", () =>
Effect.gen(function* () {
const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") }))
const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(error.message).toBe("registry denied access")
const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) }))
const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(unavailable.message).toBe("Failed to update with npm")
expect(failed.commands).toHaveLength(1)
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === method ? "@opencode-ai/cli@2.3.4" : "opencode-ai@1.0.0"),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
])
}),
)
})
it.live("method detection tolerates unavailable package managers", () =>
Effect.gen(function* () {
const test = yield* fixture((command) =>
command.command === "yarn"
? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") }
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
}),
)
test("Node distribution honors the compile-time CLI name", async () => {
const child = Bun.spawn(
[
process.execPath,
"test",
import.meta.path,
"--define",
'OPENCODE_CLI_NAME="opencode2-node"',
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
expect(stderr).toContain("1 pass")
})
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "opencode-node"],
["pnpm", "list", "-g", "--depth=0", "opencode-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["npm", "install", "--global", "opencode-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"],
])
}),
)
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("upgrade command", () => {
test("is registered in root help and documents its options", async () => {
const root = await cli(["--help"], {}, "../src/index.ts")
const help = await cli(["upgrade", "--help"], {}, "../src/index.ts")
expect(root.exitCode).toBe(0)
expect(root.stdout).toContain("upgrade")
expect(help.exitCode).toBe(0)
expect(help.stdout).toContain("[<target>]")
expect(help.stdout).toContain("--method")
expect(help.stdout).toContain("-m")
})
test("detects the installation method and resolves the latest version", async () => {
const result = await cli([])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
expect(result.stdout).toContain("Upgrade complete")
})
test("accepts an explicit version and method without detection or a version lookup", async () => {
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }])
expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target")
})
test("accepts the short method flag and an explicit major upgrade", async () => {
const result = await cli(["2.0.0", "-m", "bun"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})
test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("already installed")
})
test("requires an explicit method when detection fails", async () => {
const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("Pass --method")
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
test("reports version lookup failures without installing", async () => {
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method", "latest"])
expect(result.stdout).toContain("Update check failed")
})
test("reports installation failures with a nonzero exit code", async () => {
const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Upgrade failed")
expect(result.stdout).toContain("Permission denied")
expect(result.stdout).not.toContain("Upgrade complete")
})
})
async function cli(args: string[], env: Record<string, string> = {}, entry = "fixture/upgrade.ts") {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-"))
try {
const child = Bun.spawn(
[process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args],
{
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
OPENCODE_TEST_HOME: root,
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
...env,
},
stdout: "pipe",
stderr: "pipe",
},
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
const events = stdout
.split("\n")
.filter((line) => line.startsWith("EVENT "))
.map((line) => JSON.parse(line.slice(6)))
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
return { stdout, stderr, exitCode, events }
} finally {
await rm(root, { recursive: true, force: true })
}
}
+18 -19
View File
@@ -3,7 +3,7 @@ export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => Promise<void>
push: (value: A) => void
finish: (completion: Completion) => void
}
type Connection = {
@@ -13,7 +13,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
let current: Connection | undefined
const delivered = Promise.resolve()
const capacity = 4_096
function stop(connection: Connection) {
connection.connected = undefined
@@ -31,7 +31,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
if (item.value.type === "server.connected") connection.connected = item.value
await Promise.all(Array.from(connection.subscribers, (subscriber) => subscriber.push(item.value)))
connection.subscribers.forEach((subscriber) => subscriber.push(item.value))
}
} catch (error) {
completion = { error }
@@ -51,15 +51,14 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
const queued: A[] = []
let started = false
let completion: Completion | undefined
let connection: Connection | undefined
let offered: { readonly value: A; readonly accepted: ReturnType<typeof Promise.withResolvers<void>> } | undefined
function finish(result: Completion) {
function finish(result: Completion, discard = true) {
completion = result
offered?.accepted.resolve()
offered = undefined
if (discard) queued.length = 0
options?.signal?.removeEventListener("abort", abort)
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
pending.splice(0).forEach((request) => {
@@ -73,17 +72,21 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
const subscriber: Subscriber = {
finish,
finish(result) {
finish(result, false)
},
push(value) {
if (completion) return delivered
if (completion) return
const request = pending.shift()
if (request) {
request.resolve({ done: false, value })
return delivered
return
}
const accepted = Promise.withResolvers<void>()
offered = { value, accepted }
return accepted.promise
if (queued.length === capacity) {
finish({ error: new Error(`Event subscriber exceeded its ${capacity}-event capacity`) })
return
}
queued.push(value)
},
}
@@ -102,12 +105,8 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
return {
next(): Promise<IteratorResult<A>> {
if (offered) {
const current = offered
offered = undefined
current.accepted.resolve()
return Promise.resolve({ done: false, value: current.value })
}
const value = queued.shift()
if (value) return Promise.resolve({ done: false, value })
if (completion) {
if ("error" in completion) return Promise.reject(completion.error)
return Promise.resolve({ done: true, value: undefined })
+6 -2
View File
@@ -618,13 +618,17 @@ export function createData(config: CreateDataInput) {
})
.catch((error) => console.error("Failed to load projected model switch message", error))
return
case "session.renamed":
case "session.renamed": {
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
const family = sync.pending(`session.family:${event.data.sessionID}`)
? result.session.sync(event.data.sessionID, { children: true })
: Promise.resolve()
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
return
}
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
+59 -1
View File
@@ -97,7 +97,11 @@ test("multiple consumers share one source and receive live native and RPC events
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [{ type: "server.connected" }, { type: "session.updated" }, { type: "rpc.example.updated", value: 1 }]) {
for (const event of [
{ type: "server.connected" },
{ type: "session.updated" },
{ type: "rpc.example.updated", value: 1 },
]) {
const reads = [first.next(), second.next()]
events.connections[0].push(event)
expect(await Promise.all(reads)).toEqual([
@@ -115,6 +119,60 @@ test("multiple consumers share one source and receive live native and RPC events
await events.connections[0].closed
})
test("an idle consumer does not stall events for an active consumer", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const idle = shared.subscribe()[Symbol.asyncIterator]()
const active = shared.subscribe()[Symbol.asyncIterator]()
const connected = [idle.next(), active.next()]
events.connections[0].push({ type: "server.connected" })
await Promise.all(connected)
for (const event of [
{ type: "session.updated", value: 1 },
{ type: "session.updated", value: 2 },
]) {
const next = active.next()
events.connections[0].push(event)
expect(
await Promise.race([next, Bun.sleep(1_000).then(() => ({ done: true as const, value: { type: "timeout" } }))]),
).toEqual({ done: false, value: event })
}
await idle.return!()
await active.return!()
await events.connections[0].closed
})
test("an idle consumer fails instead of buffering events without bound", async () => {
const events = source()
const idle = SharedEvents.make(events.connect).subscribe()[Symbol.asyncIterator]()
const connected = idle.next()
events.connections[0].push({ type: "server.connected" })
await connected
Array.from({ length: 4_097 }, (_, value) => events.connections[0].push({ type: "session.updated", value }))
await events.connections[0].closed
await expect(idle.next()).rejects.toThrow("Event subscriber exceeded its 4096-event capacity")
})
test("source completion preserves events already buffered for an idle consumer", async () => {
const events = source()
const idle = SharedEvents.make(events.connect).subscribe()[Symbol.asyncIterator]()
const connected = idle.next()
events.connections[0].push({ type: "server.connected" })
await connected
events.connections[0].push({ type: "session.updated", value: 1 })
events.connections[0].push({ type: "session.updated", value: 2 })
events.connections[0].close()
await events.connections[0].closed
await Bun.sleep(0)
expect(await idle.next()).toEqual({ done: false, value: { type: "session.updated", value: 1 } })
expect(await idle.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
expect(await idle.next()).toEqual({ done: true, value: undefined })
})
test("late consumers receive the latest connection marker but no business event replay", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
+57
View File
@@ -73,6 +73,63 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("preserves a live session rename across concurrent session and family reads", async () => {
const family = Promise.withResolvers<void>()
const renamed = Promise.withResolvers<void>()
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
let requests = 0
const stale = { ...session(0), title: undefined }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith(`/api/session/${stale.id}`)) return Response.json({ data: [], cursor: {} })
requests++
await (requests === 1 ? family.promise : renamed.promise)
return Response.json({ data: stale })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
const initial = setup.data.session.sync(stale.id, { children: true })
await wait(() => requests === 1)
const event: OpenCodeEvent = {
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: stale.id, seq: 1, version: 1 },
data: { sessionID: stale.id, title: "Generated title" },
}
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await wait(() => requests === 2)
renamed.resolve()
await wait(() => setup.data.session.get(stale.id) !== undefined)
family.resolve()
await initial
await Bun.sleep(0)
expect(setup.data.session.get(stale.id)?.title).toBe("Generated title")
} finally {
family.resolve()
renamed.resolve()
setup.dispose()
}
})
test("updates authoritative cached project metadata from live events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const original: Project = {
+18 -5
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,9 +60,22 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
```ts
const runtime = CodeMode.make({
tools: {
orders: Namespace.make({
description: "Purchases, fulfillment, and shipment tracking",
tools: { lookup: lookupOrder },
}),
},
})
```
Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come
from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -150,7 +163,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`.
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
## Execution Limits
+1
View File
@@ -1,4 +1,5 @@
export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
+24
View File
@@ -0,0 +1,24 @@
import type { Tools } from "./tools.js"
/** A tool namespace with optional model-visible metadata. */
export type Namespace<R = never> = {
readonly _tag: "CodeModeNamespace"
readonly description?: string
readonly tools: Tools<R>
}
/** Options for declaring one CodeMode namespace. */
export type Options<R = never> = {
readonly description?: string
readonly tools: Tools<R>
}
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
/** Declares a namespace when descriptions or other namespace metadata are needed. */
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
_tag: "CodeModeNamespace",
...(options.description === undefined ? {} : { description: options.description }),
tools: options.tools,
})
+34 -25
View File
@@ -8,6 +8,7 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
@@ -277,6 +278,7 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
tool?: Tool<R>
namespace?: Namespace<R>
readonly children: Map<string, ToolNode<R>>
}
@@ -292,7 +294,10 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else insert(current, value)
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
}
}
insert(root, tools)
@@ -302,29 +307,33 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
type VisibleTool<R> = {
readonly path: string
readonly tool: Tool<R>
readonly namespaces: ReadonlyArray<Namespace<R>>
}
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
namespaces: ReadonlyArray<Namespace<R>> = [],
): Array<VisibleTool<R>> => {
const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace]
return [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]),
...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
]
}
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
})
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools))
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -420,12 +429,13 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
searchText: [
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -433,14 +443,13 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
}
}
+1 -1
View File
@@ -180,7 +180,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
return renderSchema(schema, { definitions: {}, pretty })
} catch {
return "unknown"
}
+4 -7
View File
@@ -1,4 +1,6 @@
import { Effect, Schema } from "effect"
import type { Namespace } from "./namespace.js"
import type { Tools } from "./tools.js"
/**
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
@@ -50,13 +52,8 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeTool"
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Tools<R>
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
}
+7 -3
View File
@@ -25,8 +25,12 @@ const happyPathSpec = async (): Promise<Document> => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const toolAt = (tools: OpenAPI.Tools, name: string) =>
name
.split(".")
.reduce<
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -948,7 +952,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = isRecord(health) ? health.input : undefined
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+29
View File
@@ -216,6 +216,35 @@ describe("pretty signature rendering", () => {
})
})
describe("JSON Schema definition scope", () => {
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
expect(jsonSchemaToTypeScript(schema)).toBe("string")
expect(jsonSchemaToTypeScript(schema, true)).toBe("string")
const overridden = { ...schema, $defs: { Value: { type: "number" } } }
expect(jsonSchemaToTypeScript(overridden)).toBe("number")
expect(jsonSchemaToTypeScript(overridden, true)).toBe("number")
})
test.each(["definitions", "$defs"])("nested %s shadow inherited definitions without affecting siblings", (key) => {
const schema = {
type: "object",
definitions: { Inherited: { type: "string" } },
$defs: { Value: { type: "number" } },
properties: {
nested: { $ref: `#/${key}/Value`, [key]: { Value: { type: "boolean" } } },
inherited: { $ref: "#/definitions/Inherited" },
sibling: { $ref: "#/$defs/Value" },
},
}
expect(jsonSchemaToTypeScript(schema)).toBe("{ nested?: boolean; inherited?: string; sibling?: number }")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " nested?: boolean,", " inherited?: string,", " sibling?: number,", "}"].join("\n"),
)
})
})
describe("non-identifier property names render as quoted keys", () => {
// MCP-style schemas routinely carry property names that are not bare TS identifiers
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
+43 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,6 +177,48 @@ describe("blocked member names on tool paths", () => {
})
})
describe("namespace metadata", () => {
const tools = {
api: Namespace.make({
description: "Manage the workspace",
tools: {
users: Namespace.make({
description: "Directory and account administration",
tools: { list: echo("List users", "users") },
}),
status: echo("Read service status", "ok"),
},
}),
plain: { read: echo("Read plain data", "plain") },
}
const runtime = CodeMode.make({ tools })
test("the wrapper does not add a segment to callable paths", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
})
test("search matches descriptions from every enclosing namespace", async () => {
const workspace = await value(runtime, `return search({ query: "workspace" })`)
expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.status",
"tools.api.users.list",
])
const directory = await value(runtime, `return search({ query: "account administration" })`)
expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.users.list",
])
})
test("a namespace description is optional", async () => {
const optional = CodeMode.make({
tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) },
})
expect(await value(optional, `return await tools.api.read({})`)).toBe("read")
})
})
describe("empty segments", () => {
test("tool names with empty segments are rejected at make", () => {
for (const name of ["", "a..b", "trail.", ".lead"]) {
+4 -13
View File
@@ -1,20 +1,11 @@
import { buildLocationServiceMap } from "../location-services.js"
import { LocationServiceMap } from "../location-service-map.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
return LayerNode.compile(root, {
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
})
}
export * as AppNodeBuilder from "./app-node-builder.js"
+2 -3
View File
@@ -54,9 +54,8 @@ const layer = Layer.effect(
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = state
.get()
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
const extension = path.extname(filepath)
const matching = state.get().formatters.filter((formatter) => formatter.extensions.includes(extension))
for (const formatter of matching) {
const enabled = yield* command(formatter)
+10 -16
View File
@@ -55,6 +55,7 @@ import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
export * as Instance from "./instance.js"
export { Service, byLocationNode, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
@@ -110,9 +111,9 @@ const nodes = [
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
export const graph = LayerNode.group<typeof nodes>(nodes)
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
@@ -141,29 +142,23 @@ export interface Options {
// source still honors explicit plugin operations from wellknown and
// host-injected config.
const vanillaReplacements: LayerNode.Replacements = [
[Config.node, Config.configured({ project: false, global: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
Config.node.replace(Config.configured({ project: false, global: false })),
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })),
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}) {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then bound pairs (which win over everything).
const allReplacements: LayerNode.Replacements = [
// defaults), then instance bindings (which win over everything).
const replacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
]
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -171,6 +166,5 @@ export function layer(ref: Location.Ref, options: Options = {}) {
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
+42
View File
@@ -0,0 +1,42 @@
export * as Instance from "./service.js"
export type { Services } from "../instance.js"
import { Context, Effect, Layer, Option, Scope } from "effect"
import type { Session } from "@opencode-ai/schema/session"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { Services } from "../instance.js"
import { LocationServiceMap } from "../location-service-map.js"
/** Selects Session capabilities; implementations own caching and lifetime. */
export interface Interface {
readonly provide: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Services>>
/** Borrow a cached instance without initializing one when it is absent. */
readonly provideIfLoaded: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return Service.of({
provide: (session) => Effect.provide(locations.get(session.location)),
provideIfLoaded: (session) => (effect) =>
// Scope the borrowed reference without replacing the caller's Scope.
Effect.scopedWith((scope) =>
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(session.location).pipe(Scope.provide(scope))
if (Option.isNone(context)) return Option.none()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
),
})
}),
)
export const byLocationNode = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] })

Some files were not shown because too many files have changed in this diff Show More