summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/package.json3
-rw-r--r--packages/app/src/addons/serialize.test.ts2
-rw-r--r--packages/app/src/addons/serialize.ts43
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx82
-rw-r--r--packages/app/src/components/dialog-select-model.tsx14
-rw-r--r--packages/app/src/components/dialog-select-server.tsx98
-rw-r--r--packages/app/src/components/prompt-input.tsx314
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts67
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts174
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx82
-rw-r--r--packages/app/src/components/prompt-input/drag-overlay.tsx20
-rw-r--r--packages/app/src/components/prompt-input/image-attachments.tsx51
-rw-r--r--packages/app/src/components/prompt-input/placeholder.test.ts35
-rw-r--r--packages/app/src/components/prompt-input/placeholder.ts13
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx144
-rw-r--r--packages/app/src/components/prompt-input/submit.ts222
-rw-r--r--packages/app/src/components/server/server-row.tsx77
-rw-r--r--packages/app/src/components/status-popover.tsx127
-rw-r--r--packages/app/src/components/terminal.tsx23
-rw-r--r--packages/app/src/context/command-keybind.test.ts43
-rw-r--r--packages/app/src/context/file-content-eviction-accounting.test.ts40
-rw-r--r--packages/app/src/context/file.tsx716
-rw-r--r--packages/app/src/context/file/content-cache.ts88
-rw-r--r--packages/app/src/context/file/path.test.ts27
-rw-r--r--packages/app/src/context/file/path.ts119
-rw-r--r--packages/app/src/context/file/tree-store.ts170
-rw-r--r--packages/app/src/context/file/types.ts41
-rw-r--r--packages/app/src/context/file/view-cache.ts136
-rw-r--r--packages/app/src/context/file/watcher.test.ts118
-rw-r--r--packages/app/src/context/file/watcher.ts52
-rw-r--r--packages/app/src/context/global-sync.tsx1225
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts195
-rw-r--r--packages/app/src/context/global-sync/child-store.ts263
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts201
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts319
-rw-r--r--packages/app/src/context/global-sync/eviction.ts28
-rw-r--r--packages/app/src/context/global-sync/queue.ts83
-rw-r--r--packages/app/src/context/global-sync/session-load.ts26
-rw-r--r--packages/app/src/context/global-sync/session-trim.test.ts59
-rw-r--r--packages/app/src/context/global-sync/session-trim.ts56
-rw-r--r--packages/app/src/context/global-sync/types.ts134
-rw-r--r--packages/app/src/context/global-sync/utils.ts25
-rw-r--r--packages/app/src/context/language.tsx20
-rw-r--r--packages/app/src/context/layout-scroll.test.ts83
-rw-r--r--packages/app/src/context/server.tsx16
-rw-r--r--packages/app/src/context/sync-optimistic.test.ts56
-rw-r--r--packages/app/src/context/sync.tsx75
-rw-r--r--packages/app/src/i18n/ar.ts4
-rw-r--r--packages/app/src/i18n/br.ts4
-rw-r--r--packages/app/src/i18n/da.ts4
-rw-r--r--packages/app/src/i18n/de.ts43
-rw-r--r--packages/app/src/i18n/en.ts38
-rw-r--r--packages/app/src/i18n/es.ts4
-rw-r--r--packages/app/src/i18n/fr.ts4
-rw-r--r--packages/app/src/i18n/ja.ts4
-rw-r--r--packages/app/src/i18n/ko.ts4
-rw-r--r--packages/app/src/i18n/no.ts4
-rw-r--r--packages/app/src/i18n/parity.test.ts31
-rw-r--r--packages/app/src/i18n/pl.ts4
-rw-r--r--packages/app/src/i18n/ru.ts4
-rw-r--r--packages/app/src/i18n/th.ts4
-rw-r--r--packages/app/src/i18n/zh.ts42
-rw-r--r--packages/app/src/i18n/zht.ts42
-rw-r--r--packages/app/src/pages/directory-layout.tsx2
-rw-r--r--packages/app/src/pages/layout.tsx1296
-rw-r--r--packages/app/src/pages/layout/deep-links.ts26
-rw-r--r--packages/app/src/pages/layout/helpers.test.ts63
-rw-r--r--packages/app/src/pages/layout/helpers.ts65
-rw-r--r--packages/app/src/pages/layout/inline-editor.tsx113
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx330
-rw-r--r--packages/app/src/pages/layout/sidebar-project-helpers.test.ts63
-rw-r--r--packages/app/src/pages/layout/sidebar-project-helpers.ts11
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx283
-rw-r--r--packages/app/src/pages/layout/sidebar-shell-helpers.ts1
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.test.ts13
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx109
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace-helpers.ts2
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.test.ts13
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx387
-rw-r--r--packages/app/src/pages/session.tsx2384
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.test.ts40
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.ts67
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx516
-rw-r--r--packages/app/src/pages/session/message-gesture.test.ts62
-rw-r--r--packages/app/src/pages/session/message-gesture.ts21
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx348
-rw-r--r--packages/app/src/pages/session/review-tab.tsx158
-rw-r--r--packages/app/src/pages/session/session-command-helpers.ts10
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx36
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.test.ts22
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx137
-rw-r--r--packages/app/src/pages/session/session-prompt-helpers.ts4
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx306
-rw-r--r--packages/app/src/pages/session/terminal-label.ts16
-rw-r--r--packages/app/src/pages/session/terminal-panel.test.ts25
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx169
-rw-r--r--packages/app/src/pages/session/use-session-commands.test.ts44
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx439
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.test.ts16
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts174
-rw-r--r--packages/app/src/utils/runtime-adapters.test.ts62
-rw-r--r--packages/app/src/utils/runtime-adapters.ts39
-rw-r--r--packages/app/src/utils/server-health.test.ts42
-rw-r--r--packages/app/src/utils/server-health.ts29
-rw-r--r--packages/app/src/utils/speech.ts12
-rw-r--r--packages/app/src/utils/worktree.test.ts46
106 files changed, 8422 insertions, 5824 deletions
diff --git a/packages/app/package.json b/packages/app/package.json
index 12b805360..a995880e0 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -15,7 +15,8 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
- "test:unit": "bun test ./src",
+ "test:unit": "bun test --preload ./happydom.ts ./src",
+ "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts
index 7fb1a61f3..7f6780557 100644
--- a/packages/app/src/addons/serialize.test.ts
+++ b/packages/app/src/addons/serialize.test.ts
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
})
}
-describe.skip("SerializeAddon", () => {
+describe("SerializeAddon", () => {
describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal()
diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts
index 3f0a8fb0a..4cab55b3f 100644
--- a/packages/app/src/addons/serialize.ts
+++ b/packages/app/src/addons/serialize.ts
@@ -56,6 +56,39 @@ interface IBufferCell {
isDim(): boolean
}
+type TerminalBuffers = {
+ active?: IBuffer
+ normal?: IBuffer
+ alternate?: IBuffer
+}
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+ return typeof value === "object" && value !== null
+}
+
+const isBuffer = (value: unknown): value is IBuffer => {
+ if (!isRecord(value)) return false
+ if (typeof value.length !== "number") return false
+ if (typeof value.cursorX !== "number") return false
+ if (typeof value.cursorY !== "number") return false
+ if (typeof value.baseY !== "number") return false
+ if (typeof value.viewportY !== "number") return false
+ if (typeof value.getLine !== "function") return false
+ if (typeof value.getNullCell !== "function") return false
+ return true
+}
+
+const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
+ if (!isRecord(value)) return
+ const raw = value.buffer
+ if (!isRecord(raw)) return
+ const active = isBuffer(raw.active) ? raw.active : undefined
+ const normal = isBuffer(raw.normal) ? raw.normal : undefined
+ const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
+ if (!active && !normal) return
+ return { active, normal, alternate }
+}
+
// ============================================================================
// Types
// ============================================================================
@@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded")
}
- const terminal = this._terminal as any
- const buffer = terminal.buffer
+ const buffer = getTerminalBuffers(this._terminal)
if (!buffer) {
return ""
}
- const normalBuffer = buffer.normal || buffer.active
+ const normalBuffer = buffer.normal ?? buffer.active
const altBuffer = buffer.alternate
if (!normalBuffer) {
@@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded")
}
- const terminal = this._terminal as any
- const buffer = terminal.buffer
+ const buffer = getTerminalBuffers(this._terminal)
if (!buffer) {
return ""
}
- const activeBuffer = buffer.active || buffer.normal
+ const activeBuffer = buffer.active ?? buffer.normal
if (!activeBuffer) {
return ""
}
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx
index 28a947f3b..53773ed9e 100644
--- a/packages/app/src/components/dialog-custom-provider.tsx
+++ b/packages/app/src/components/dialog-custom-provider.tsx
@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
- ? "Provider ID is required"
+ ? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
- ? "Use lowercase letters, numbers, hyphens, or underscores"
+ ? language.t("provider.custom.error.providerID.format")
: undefined
- const nameError = !name ? "Display name is required" : undefined
+ const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
- ? "Base URL is required"
+ ? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
- ? "Must start with http:// or https://"
+ ? language.t("provider.custom.error.baseURL.format")
: undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
const existsError = idError
? undefined
: existingProvider && !disabled
- ? "That provider ID already exists"
+ ? language.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
- ? "Required"
+ ? language.t("provider.custom.error.required")
: seenModels.has(id)
- ? "Duplicate"
+ ? language.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
- const modelNameError = !m.name.trim() ? "Required" : undefined
+ const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
if (!key && !value) return {}
const keyError = !key
- ? "Required"
+ ? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
- ? "Duplicate"
+ ? language.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
- const valueError = !value ? "Required" : undefined
+ const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
- <div class="text-16-medium text-text-strong">Custom provider</div>
+ <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
- Configure an OpenAI-compatible provider. See the{" "}
+ {language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
- provider config docs
+ {language.t("provider.custom.description.link")}
</Link>
- .
+ {language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
- label="Provider ID"
- placeholder="myprovider"
- description="Lowercase letters, numbers, hyphens, or underscores"
+ label={language.t("provider.custom.field.providerID.label")}
+ placeholder={language.t("provider.custom.field.providerID.placeholder")}
+ description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={setForm.bind(null, "providerID")}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID}
/>
<TextField
- label="Display name"
- placeholder="My AI Provider"
+ label={language.t("provider.custom.field.name.label")}
+ placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={setForm.bind(null, "name")}
validationState={errors.name ? "invalid" : undefined}
error={errors.name}
/>
<TextField
- label="Base URL"
- placeholder="https://api.myprovider.com/v1"
+ label={language.t("provider.custom.field.baseURL.label")}
+ placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={setForm.bind(null, "baseURL")}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL}
/>
<TextField
- label="API key"
- placeholder="API key"
- description="Optional. Leave empty if you manage auth via headers."
+ label={language.t("provider.custom.field.apiKey.label")}
+ placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+ description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={setForm.bind(null, "apiKey")}
/>
</div>
<div class="flex flex-col gap-3">
- <label class="text-12-medium text-text-weak">Models</label>
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
- label="ID"
+ label={language.t("provider.custom.models.id.label")}
hideLabel
- placeholder="model-id"
+ placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setForm("models", i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined}
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
</div>
<div class="flex-1">
<TextField
- label="Name"
+ label={language.t("provider.custom.models.name.label")}
hideLabel
- placeholder="Display Name"
+ placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setForm("models", i(), "name", v)}
validationState={errors.models[i()]?.name ? "invalid" : undefined}
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
- aria-label="Remove model"
+ aria-label={language.t("provider.custom.models.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
- Add model
+ {language.t("provider.custom.models.add")}
</Button>
</div>
<div class="flex flex-col gap-3">
- <label class="text-12-medium text-text-weak">Headers (optional)</label>
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
- label="Header"
+ label={language.t("provider.custom.headers.key.label")}
hideLabel
- placeholder="Header-Name"
+ placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setForm("headers", i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
</div>
<div class="flex-1">
<TextField
- label="Value"
+ label={language.t("provider.custom.headers.value.label")}
hideLabel
- placeholder="value"
+ placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setForm("headers", i(), "value", v)}
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
class="mt-1.5"
onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1}
- aria-label="Remove header"
+ aria-label={language.t("provider.custom.headers.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
- Add header
+ {language.t("provider.custom.headers.add")}
</Button>
</div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
- {form.saving ? "Saving..." : language.t("common.submit")}
+ {form.saving ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 3d0d6c793..26021f06a 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -87,11 +87,13 @@ const ModelList: Component<{
)
}
-export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
+type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+
+export function ModelSelectorPopover(props: {
provider?: string
children?: JSX.Element
- triggerAs?: T
- triggerProps?: ComponentProps<T>
+ triggerAs?: ValidComponent
+ triggerProps?: ModelSelectorTriggerProps
}) {
const [store, setStore] = createStore<{
open: boolean
@@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
placement="top-start"
gutter={8}
>
- <Kobalte.Trigger
- ref={(el) => setStore("trigger", el)}
- as={props.triggerAs ?? "div"}
- {...(props.triggerProps as any)}
- >
+ <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index 3d8f5b846..65b679f70 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -1,4 +1,4 @@
-import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
+import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useGlobalSDK } from "@/context/global-sdk"
import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps {
value: string
@@ -40,19 +38,6 @@ interface EditRowProps {
onBlur: () => void
}
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
- .catch(() => ({ healthy: false }))
-}
-
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [store, setStore] = createStore({
- status: {} as Record<string, ServerStatus | undefined>,
+ status: {} as Record<string, ServerHealth | undefined>,
addServer: {
url: "",
adding: false,
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
+ const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerStatus) => {
+ const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
})
async function refreshHealth() {
- const results: Record<string, ServerStatus> = {}
+ const results: Record<string, ServerHealth> = {}
await Promise.all(
items().map(async (url) => {
- results[url] = await checkHealth(url, platform)
+ results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
setStore("editServer", { busy: true, error: "" })
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
@@ -413,35 +399,6 @@ export function DialogSelectServer() {
}
>
{(i) => {
- const [truncated, setTruncated] = createSignal(false)
- let nameRef: HTMLSpanElement | undefined
- let versionRef: HTMLSpanElement | undefined
-
- const check = () => {
- const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
- const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
- setTruncated(nameTruncated || versionTruncated)
- }
-
- createEffect(() => {
- check()
- window.addEventListener("resize", check)
- onCleanup(() => window.removeEventListener("resize", check))
- })
-
- const tooltipValue = () => {
- const name = serverDisplayName(i)
- const version = store.status[i]?.version
- return (
- <span class="flex items-center gap-2">
- <span>{name}</span>
- <Show when={version}>
- <span class="text-text-invert-base">{version}</span>
- </Show>
- </span>
- )
- }
-
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
@@ -459,34 +416,19 @@ export function DialogSelectServer() {
/>
}
>
- <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
- <div
- class="flex items-center gap-3 px-4 min-w-0 flex-1"
- classList={{ "opacity-50": store.status[i]?.healthy === false }}
- >
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": store.status[i]?.healthy === true,
- "bg-icon-critical-base": store.status[i]?.healthy === false,
- "bg-border-weak-base": store.status[i] === undefined,
- }}
- />
- <span ref={nameRef} class="truncate">
- {serverDisplayName(i)}
- </span>
- <Show when={store.status[i]?.version}>
- <span ref={versionRef} class="text-text-weak text-14-regular truncate">
- {store.status[i]?.version}
- </span>
- </Show>
+ <ServerRow
+ url={i}
+ status={store.status[i]}
+ dimmed={store.status[i]?.healthy === false}
+ class="flex items-center gap-3 px-4 min-w-0 flex-1"
+ badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
- </div>
- </Tooltip>
+ }
+ />
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 3f0ba314e..46d7f93eb 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -19,7 +19,6 @@ import { useSDK } from "@/context/sdk"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
@@ -27,9 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
-import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@@ -42,6 +39,12 @@ import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
+import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
+import { PromptContextItems } from "./prompt-input/context-items"
+import { PromptImageAttachments } from "./prompt-input/image-attachments"
+import { PromptDragOverlay } from "./prompt-input/drag-overlay"
+import { promptPlaceholder } from "./prompt-input/placeholder"
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
interface PromptInputProps {
class?: string
@@ -79,16 +82,6 @@ const EXAMPLES = [
"prompt.example.25",
] as const
-interface SlashCommand {
- id: string
- trigger: string
- title: string
- description?: string
- keybind?: string
- type: "builtin" | "custom"
- source?: "command" | "mcp" | "skill"
-}
-
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
@@ -203,8 +196,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
- const imageAttachments = createMemo(
- () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
+ const imageAttachments = createMemo(() =>
+ prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const [store, setStore] = createStore<{
@@ -224,6 +217,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal",
applyingHistory: false,
})
+ const placeholder = createMemo(() =>
+ promptPlaceholder({
+ mode: store.mode,
+ commentCount: commentCount(),
+ example: language.t(EXAMPLES[store.placeholder]),
+ t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
+ }),
+ )
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
@@ -296,10 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setComposing(false)
})
- type AtOption =
- | { type: "agent"; name: string; display: string }
- | { type: "file"; path: string; display: string; recent?: boolean }
-
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -509,7 +506,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on(
() => prompt.current(),
(currentParts) => {
- const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
+ const inputParts = currentParts.filter((part) => part.type !== "image")
if (mirror.input) {
mirror.input = false
@@ -928,110 +925,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
- <Show when={store.popover}>
- <div
- ref={(el) => {
- if (store.popover === "slash") slashPopoverRef = el
- }}
- class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
- overflow-auto no-scrollbar flex flex-col p-2 rounded-md
- border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
- onMouseDown={(e) => e.preventDefault()}
- >
- <Switch>
- <Match when={store.popover === "at"}>
- <Show
- when={atFlat().length > 0}
- fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyResults")}</div>}
- >
- <For each={atFlat().slice(0, 10)}>
- {(item) => (
- <button
- classList={{
- "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
- "bg-surface-raised-base-hover": atActive() === atKey(item),
- }}
- onClick={() => handleAtSelect(item)}
- onMouseEnter={() => setAtActive(atKey(item))}
- >
- <Show
- when={item.type === "agent"}
- fallback={
- <>
- <FileIcon
- node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
- class="shrink-0 size-4"
- />
- <div class="flex items-center text-14-regular min-w-0">
- <span class="text-text-weak whitespace-nowrap truncate min-w-0">
- {(() => {
- const path = (item as { type: "file"; path: string }).path
- return path.endsWith("/") ? path : getDirectory(path)
- })()}
- </span>
- <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
- <span class="text-text-strong whitespace-nowrap">
- {getFilename((item as { type: "file"; path: string }).path)}
- </span>
- </Show>
- </div>
- </>
- }
- >
- <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
- <span class="text-14-regular text-text-strong whitespace-nowrap">
- @{(item as { type: "agent"; name: string }).name}
- </span>
- </Show>
- </button>
- )}
- </For>
- </Show>
- </Match>
- <Match when={store.popover === "slash"}>
- <Show
- when={slashFlat().length > 0}
- fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyCommands")}</div>}
- >
- <For each={slashFlat()}>
- {(cmd) => (
- <button
- data-slash-id={cmd.id}
- classList={{
- "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
- "bg-surface-raised-base-hover": slashActive() === cmd.id,
- }}
- onClick={() => handleSlashSelect(cmd)}
- onMouseEnter={() => setSlashActive(cmd.id)}
- >
- <div class="flex items-center gap-2 min-w-0">
- <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
- <Show when={cmd.description}>
- <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
- </Show>
- </div>
- <div class="flex items-center gap-2 shrink-0">
- <Show when={cmd.type === "custom" && cmd.source !== "command"}>
- <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
- {cmd.source === "skill"
- ? language.t("prompt.slash.badge.skill")
- : cmd.source === "mcp"
- ? language.t("prompt.slash.badge.mcp")
- : language.t("prompt.slash.badge.custom")}
- </span>
- </Show>
- <Show when={command.keybind(cmd.id)}>
- <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
- </Show>
- </div>
- </button>
- )}
- </For>
- </Show>
- </Match>
- </Switch>
- </div>
- </Show>
+ <PromptPopover
+ popover={store.popover}
+ setSlashPopoverRef={(el) => (slashPopoverRef = el)}
+ atFlat={atFlat()}
+ atActive={atActive() ?? undefined}
+ atKey={atKey}
+ setAtActive={setAtActive}
+ onAtSelect={handleAtSelect}
+ slashFlat={slashFlat()}
+ slashActive={slashActive() ?? undefined}
+ setSlashActive={setSlashActive}
+ onSlashSelect={handleSlashSelect}
+ commandKeybind={command.keybind}
+ t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+ />
<form
onSubmit={handleSubmit}
classList={{
@@ -1042,124 +950,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
[props.class ?? ""]: !!props.class,
}}
>
- <Show when={store.dragging}>
- <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
- <div class="flex flex-col items-center gap-2 text-text-weak">
- <Icon name="photo" class="size-8" />
- <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
- </div>
- </div>
- </Show>
- <Show when={prompt.context.items().length > 0}>
- <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
- <For each={prompt.context.items()}>
- {(item) => {
- const active = () => {
- const a = comments.active()
- return !!item.commentID && item.commentID === a?.id && item.path === a?.file
- }
- return (
- <Tooltip
- value={
- <span class="flex max-w-[300px]">
- <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
- {getDirectory(item.path)}
- </span>
- <span class="shrink-0">{getFilename(item.path)}</span>
- </span>
- }
- placement="top"
- openDelay={2000}
- >
- <div
- classList={{
- "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
- "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
- "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
- active(),
- "bg-background-stronger": !active(),
- }}
- onClick={() => {
- openComment(item)
- }}
- >
- <div class="flex items-center gap-1.5">
- <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
- <div class="flex items-center text-11-regular min-w-0 font-medium">
- <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
- <Show when={item.selection}>
- {(sel) => (
- <span class="text-text-weak whitespace-nowrap shrink-0">
- {sel().startLine === sel().endLine
- ? `:${sel().startLine}`
- : `:${sel().startLine}-${sel().endLine}`}
- </span>
- )}
- </Show>
- </div>
- <IconButton
- type="button"
- icon="close-small"
- variant="ghost"
- class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
- onClick={(e) => {
- e.stopPropagation()
- if (item.commentID) comments.remove(item.path, item.commentID)
- prompt.context.remove(item.key)
- }}
- aria-label={language.t("prompt.context.removeFile")}
- />
- </div>
- <Show when={item.comment}>
- {(comment) => (
- <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
- )}
- </Show>
- </div>
- </Tooltip>
- )
- }}
- </For>
- </div>
- </Show>
- <Show when={imageAttachments().length > 0}>
- <div class="flex flex-wrap gap-2 px-3 pt-3">
- <For each={imageAttachments()}>
- {(attachment) => (
- <div class="relative group">
- <Show
- when={attachment.mime.startsWith("image/")}
- fallback={
- <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
- <Icon name="folder" class="size-6 text-text-weak" />
- </div>
- }
- >
- <img
- src={attachment.dataUrl}
- alt={attachment.filename}
- class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
- onClick={() =>
- dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
- }
- />
- </Show>
- <button
- type="button"
- onClick={() => removeImageAttachment(attachment.id)}
- class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
- aria-label={language.t("prompt.attachment.remove")}
- >
- <Icon name="close" class="size-3 text-text-weak" />
- </button>
- <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
- <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
- </div>
- </div>
- )}
- </For>
- </div>
- </Show>
+ <PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} />
+ <PromptContextItems
+ items={prompt.context.items()}
+ active={(item) => {
+ const active = comments.active()
+ return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+ }}
+ openComment={openComment}
+ remove={(item) => {
+ if (item.commentID) comments.remove(item.path, item.commentID)
+ prompt.context.remove(item.key)
+ }}
+ t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+ />
+ <PromptImageAttachments
+ attachments={imageAttachments()}
+ onOpen={(attachment) =>
+ dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+ }
+ onRemove={removeImageAttachment}
+ removeLabel={language.t("prompt.attachment.remove")}
+ />
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
@@ -1169,15 +981,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
role="textbox"
aria-multiline="true"
- aria-label={
- store.mode === "shell"
- ? language.t("prompt.placeholder.shell")
- : commentCount() > 1
- ? language.t("prompt.placeholder.summarizeComments")
- : commentCount() === 1
- ? language.t("prompt.placeholder.summarizeComment")
- : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
- }
+ aria-label={placeholder()}
contenteditable="true"
onInput={handleInput}
onPaste={handlePaste}
@@ -1194,13 +998,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
- {store.mode === "shell"
- ? language.t("prompt.placeholder.shell")
- : commentCount() > 1
- ? language.t("prompt.placeholder.summarizeComments")
- : commentCount() === 1
- ? language.t("prompt.placeholder.summarizeComment")
- : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
+ {placeholder()}
</div>
</Show>
</div>
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
new file mode 100644
index 000000000..b284c3884
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { buildRequestParts } from "./build-request-parts"
+
+describe("buildRequestParts", () => {
+ test("builds typed request and optimistic parts without cast path", () => {
+ const prompt: Prompt = [
+ { type: "text", content: "hello", start: 0, end: 5 },
+ {
+ type: "file",
+ path: "src/foo.ts",
+ content: "@src/foo.ts",
+ start: 5,
+ end: 16,
+ selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
+ },
+ { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
+ images: [
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ ],
+ text: "hello @src/foo.ts @planner",
+ messageID: "msg_1",
+ sessionID: "ses_1",
+ sessionDirectory: "/repo",
+ })
+
+ expect(result.requestParts[0]?.type).toBe("text")
+ expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
+ expect(
+ result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
+ ).toBe(true)
+ expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+
+ expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+ expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
+ })
+
+ test("deduplicates context files when prompt already includes same path", () => {
+ const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [
+ { key: "ctx:dup", type: "file", path: "src/foo.ts" },
+ { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
+ ],
+ images: [],
+ text: "@src/foo.ts",
+ messageID: "msg_2",
+ sessionID: "ses_2",
+ sessionDirectory: "/repo",
+ })
+
+ const fooFiles = result.requestParts.filter(
+ (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
+ )
+ const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
+
+ expect(fooFiles).toHaveLength(2)
+ expect(synthetic).toHaveLength(1)
+ })
+})
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
new file mode 100644
index 000000000..4cf2f29ac
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -0,0 +1,174 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
+import type { FileSelection } from "@/context/file"
+import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import { Identifier } from "@/utils/id"
+
+type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+
+type ContextFile = {
+ key: string
+ type: "file"
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+type BuildRequestPartsInput = {
+ prompt: Prompt
+ context: ContextFile[]
+ images: ImageAttachmentPart[]
+ text: string
+ messageID: string
+ sessionID: string
+ sessionDirectory: string
+}
+
+const absolute = (directory: string, path: string) =>
+ path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
+
+const fileQuery = (selection: FileSelection | undefined) =>
+ selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+
+const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
+const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
+
+const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+}
+
+const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
+ if (part.type === "text") {
+ return {
+ id: part.id,
+ type: "text",
+ text: part.text,
+ synthetic: part.synthetic,
+ ignored: part.ignored,
+ time: part.time,
+ metadata: part.metadata,
+ sessionID,
+ messageID,
+ }
+ }
+ if (part.type === "file") {
+ return {
+ id: part.id,
+ type: "file",
+ mime: part.mime,
+ filename: part.filename,
+ url: part.url,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+ }
+ return {
+ id: part.id,
+ type: "agent",
+ name: part.name,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+}
+
+export function buildRequestParts(input: BuildRequestPartsInput) {
+ const requestParts: PromptRequestPart[] = [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: input.text,
+ },
+ ]
+
+ const files = input.prompt.filter(isFileAttachment).map((attachment) => {
+ const path = absolute(input.sessionDirectory, attachment.path)
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url: `file://${path}${fileQuery(attachment.selection)}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file",
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "agent",
+ name: attachment.name,
+ source: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const used = new Set(files.map((part) => part.url))
+ const context = input.context.flatMap((item) => {
+ const path = absolute(input.sessionDirectory, item.path)
+ const url = `file://${path}${fileQuery(item.selection)}`
+ const comment = item.comment?.trim()
+ if (!comment && used.has(url)) return []
+ used.add(url)
+
+ const filePart = {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(item.path),
+ } satisfies PromptRequestPart
+
+ if (!comment) return [filePart]
+
+ return [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(item.path, item.selection, comment),
+ synthetic: true,
+ } satisfies PromptRequestPart,
+ filePart,
+ ]
+ })
+
+ const images = input.images.map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ } satisfies PromptRequestPart
+ })
+
+ requestParts.push(...files, ...context, ...agents, ...images)
+
+ return {
+ requestParts,
+ optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
+ }
+}
diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx
new file mode 100644
index 000000000..a843e109d
--- /dev/null
+++ b/packages/app/src/components/prompt-input/context-items.tsx
@@ -0,0 +1,82 @@
+import { Component, For, Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
+import type { ContextItem } from "@/context/prompt"
+
+type PromptContextItem = ContextItem & { key: string }
+
+type ContextItemsProps = {
+ items: PromptContextItem[]
+ active: (item: PromptContextItem) => boolean
+ openComment: (item: PromptContextItem) => void
+ remove: (item: PromptContextItem) => void
+ t: (key: string) => string
+}
+
+export const PromptContextItems: Component<ContextItemsProps> = (props) => {
+ return (
+ <Show when={props.items.length > 0}>
+ <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
+ <For each={props.items}>
+ {(item) => (
+ <Tooltip
+ value={
+ <span class="flex max-w-[300px]">
+ <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+ {getDirectory(item.path)}
+ </span>
+ <span class="shrink-0">{getFilename(item.path)}</span>
+ </span>
+ }
+ placement="top"
+ openDelay={2000}
+ >
+ <div
+ classList={{
+ "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+ "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
+ "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+ props.active(item),
+ "bg-background-stronger": !props.active(item),
+ }}
+ onClick={() => props.openComment(item)}
+ >
+ <div class="flex items-center gap-1.5">
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+ <div class="flex items-center text-11-regular min-w-0 font-medium">
+ <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
+ <Show when={item.selection}>
+ {(sel) => (
+ <span class="text-text-weak whitespace-nowrap shrink-0">
+ {sel().startLine === sel().endLine
+ ? `:${sel().startLine}`
+ : `:${sel().startLine}-${sel().endLine}`}
+ </span>
+ )}
+ </Show>
+ </div>
+ <IconButton
+ type="button"
+ icon="close-small"
+ variant="ghost"
+ class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+ onClick={(e) => {
+ e.stopPropagation()
+ props.remove(item)
+ }}
+ aria-label={props.t("prompt.context.removeFile")}
+ />
+ </div>
+ <Show when={item.comment}>
+ {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
+ </Show>
+ </div>
+ </Tooltip>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx
new file mode 100644
index 000000000..f5a4d399e
--- /dev/null
+++ b/packages/app/src/components/prompt-input/drag-overlay.tsx
@@ -0,0 +1,20 @@
+import { Component, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+
+type PromptDragOverlayProps = {
+ dragging: boolean
+ label: string
+}
+
+export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
+ return (
+ <Show when={props.dragging}>
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+ <div class="flex flex-col items-center gap-2 text-text-weak">
+ <Icon name="photo" class="size-8" />
+ <span class="text-14-regular">{props.label}</span>
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
new file mode 100644
index 000000000..ba3addf0a
--- /dev/null
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -0,0 +1,51 @@
+import { Component, For, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import type { ImageAttachmentPart } from "@/context/prompt"
+
+type PromptImageAttachmentsProps = {
+ attachments: ImageAttachmentPart[]
+ onOpen: (attachment: ImageAttachmentPart) => void
+ onRemove: (id: string) => void
+ removeLabel: string
+}
+
+export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
+ return (
+ <Show when={props.attachments.length > 0}>
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
+ <For each={props.attachments}>
+ {(attachment) => (
+ <div class="relative group">
+ <Show
+ when={attachment.mime.startsWith("image/")}
+ fallback={
+ <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+ <Icon name="folder" class="size-6 text-text-weak" />
+ </div>
+ }
+ >
+ <img
+ src={attachment.dataUrl}
+ alt={attachment.filename}
+ class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+ onClick={() => props.onOpen(attachment)}
+ />
+ </Show>
+ <button
+ type="button"
+ onClick={() => props.onRemove(attachment.id)}
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+ aria-label={props.removeLabel}
+ >
+ <Icon name="close" class="size-3 text-text-weak" />
+ </button>
+ <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+ <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+ </div>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts
new file mode 100644
index 000000000..b633df829
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { promptPlaceholder } from "./placeholder"
+
+describe("promptPlaceholder", () => {
+ const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
+
+ test("returns shell placeholder in shell mode", () => {
+ const value = promptPlaceholder({
+ mode: "shell",
+ commentCount: 0,
+ example: "example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.shell")
+ })
+
+ test("returns summarize placeholders for comment context", () => {
+ expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComment",
+ )
+ expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComments",
+ )
+ })
+
+ test("returns default placeholder with example", () => {
+ const value = promptPlaceholder({
+ mode: "normal",
+ commentCount: 0,
+ example: "translated-example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.normal:translated-example")
+ })
+})
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
new file mode 100644
index 000000000..07f6a43b5
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.ts
@@ -0,0 +1,13 @@
+type PromptPlaceholderInput = {
+ mode: "normal" | "shell"
+ commentCount: number
+ example: string
+ t: (key: string, params?: Record<string, string>) => string
+}
+
+export function promptPlaceholder(input: PromptPlaceholderInput) {
+ if (input.mode === "shell") return input.t("prompt.placeholder.shell")
+ if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
+ if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+ return input.t("prompt.placeholder.normal", { example: input.example })
+}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx
new file mode 100644
index 000000000..b97bb6752
--- /dev/null
+++ b/packages/app/src/components/prompt-input/slash-popover.tsx
@@ -0,0 +1,144 @@
+import { Component, For, Match, Show, Switch } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export type AtOption =
+ | { type: "agent"; name: string; display: string }
+ | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+ id: string
+ trigger: string
+ title: string
+ description?: string
+ keybind?: string
+ type: "builtin" | "custom"
+ source?: "command" | "mcp" | "skill"
+}
+
+type PromptPopoverProps = {
+ popover: "at" | "slash" | null
+ setSlashPopoverRef: (el: HTMLDivElement) => void
+ atFlat: AtOption[]
+ atActive?: string
+ atKey: (item: AtOption) => string
+ setAtActive: (id: string) => void
+ onAtSelect: (item: AtOption) => void
+ slashFlat: SlashCommand[]
+ slashActive?: string
+ setSlashActive: (id: string) => void
+ onSlashSelect: (item: SlashCommand) => void
+ commandKeybind: (id: string) => string | undefined
+ t: (key: string) => string
+}
+
+export const PromptPopover: Component<PromptPopoverProps> = (props) => {
+ return (
+ <Show when={props.popover}>
+ <div
+ ref={(el) => {
+ if (props.popover === "slash") props.setSlashPopoverRef(el)
+ }}
+ class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-md
+ border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+ onMouseDown={(e) => e.preventDefault()}
+ >
+ <Switch>
+ <Match when={props.popover === "at"}>
+ <Show
+ when={props.atFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
+ >
+ <For each={props.atFlat.slice(0, 10)}>
+ {(item) => (
+ <button
+ classList={{
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+ "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
+ }}
+ onClick={() => props.onAtSelect(item)}
+ onMouseEnter={() => props.setAtActive(props.atKey(item))}
+ >
+ <Show
+ when={item.type === "agent"}
+ fallback={
+ <>
+ <FileIcon
+ node={{ path: item.type === "file" ? item.path : "", type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex items-center text-14-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+ {item.type === "file"
+ ? item.path.endsWith("/")
+ ? item.path
+ : getDirectory(item.path)
+ : ""}
+ </span>
+ <Show when={item.type === "file" && !item.path.endsWith("/")}>
+ <span class="text-text-strong whitespace-nowrap">
+ {item.type === "file" ? getFilename(item.path) : ""}
+ </span>
+ </Show>
+ </div>
+ </>
+ }
+ >
+ <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+ <span class="text-14-regular text-text-strong whitespace-nowrap">
+ @{item.type === "agent" ? item.name : ""}
+ </span>
+ </Show>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ <Match when={props.popover === "slash"}>
+ <Show
+ when={props.slashFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
+ >
+ <For each={props.slashFlat}>
+ {(cmd) => (
+ <button
+ data-slash-id={cmd.id}
+ classList={{
+ "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+ "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+ }}
+ onClick={() => props.onSlashSelect(cmd)}
+ onMouseEnter={() => props.setSlashActive(cmd.id)}
+ >
+ <div class="flex items-center gap-2 min-w-0">
+ <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+ <Show when={cmd.description}>
+ <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+ </Show>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+ {cmd.source === "skill"
+ ? props.t("prompt.slash.badge.skill")
+ : cmd.source === "mcp"
+ ? props.t("prompt.slash.badge.mcp")
+ : props.t("prompt.slash.badge.custom")}
+ </span>
+ </Show>
+ <Show when={props.commandKeybind(cmd.id)}>
+ <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
+ </Show>
+ </div>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ </Switch>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 1e5ebe4cb..5ed5eedad 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -1,19 +1,10 @@
import { Accessor } from "solid-js"
-import { produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
-import { getFilename } from "@opencode-ai/util/path"
-import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
-import { Binary } from "@opencode-ai/util/binary"
+import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local"
-import {
- usePrompt,
- type AgentPart,
- type FileAttachmentPart,
- type ImageAttachmentPart,
- type Prompt,
-} from "@/context/prompt"
+import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
+import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = {
abort: AbortController
@@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
- const toAbsolutePath = (path: string) =>
- path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
-
- const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
- const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
-
- const fileAttachmentParts = fileAttachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: "text/plain",
- url: `file://${absolute}${query}`,
- filename: getFilename(attachment.path),
- source: {
- type: "file" as const,
- text: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- path: absolute,
- },
- }
- })
-
- const agentAttachmentParts = agentAttachments.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "agent" as const,
- name: attachment.name,
- source: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- }))
-
- const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
-
const context = prompt.context.items().slice()
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
- const contextParts: Array<
- | {
- id: string
- type: "text"
- text: string
- synthetic?: boolean
- }
- | {
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }
- > = []
-
- const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
-
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
- }
-
- const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
- const absolute = toAbsolutePath(item.path)
- const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
- const url = `file://${absolute}${query}`
-
- const comment = item.comment?.trim()
- if (!comment && usedUrls.has(url)) return
- usedUrls.add(url)
-
- if (comment) {
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "text",
- text: commentNote(item.path, item.selection, comment),
- synthetic: true,
- })
- }
-
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "file",
- mime: "text/plain",
- url,
- filename: getFilename(item.path),
- })
- }
-
- for (const item of context) {
- if (item.type !== "file") continue
- addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
- }
-
- const imageAttachmentParts = images.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- }))
-
const messageID = Identifier.ascending("message")
- const requestParts = [
- {
- id: Identifier.ascending("part"),
- type: "text" as const,
- text,
- },
- ...fileAttachmentParts,
- ...contextParts,
- ...agentAttachmentParts,
- ...imageAttachmentParts,
- ]
-
- const optimisticParts = requestParts.map((part) => ({
- ...part,
+ const { requestParts, optimisticParts } = buildRequestParts({
+ prompt: currentPrompt,
+ context,
+ images,
+ text,
sessionID: session.id,
messageID,
- })) as unknown as Part[]
+ sessionDirectory,
+ })
const optimisticMessage: Message = {
id: messageID,
@@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
model,
}
- const addOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((part) => !!part?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }),
- )
- return
- }
-
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((part) => !!part?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }),
- )
- }
-
- const removeOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- return
- }
+ const addOptimisticMessage = () =>
+ sync.session.optimistic.add({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ message: optimisticMessage,
+ parts: optimisticParts,
+ })
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- }
+ const removeOptimisticMessage = () =>
+ sync.session.optimistic.remove({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ messageID,
+ })
removeCommentItems(commentItems)
clearInput()
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx
new file mode 100644
index 000000000..b43c07882
--- /dev/null
+++ b/packages/app/src/components/server/server-row.tsx
@@ -0,0 +1,77 @@
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { serverDisplayName } from "@/context/server"
+import type { ServerHealth } from "@/utils/server-health"
+
+interface ServerRowProps extends ParentProps {
+ url: string
+ status?: ServerHealth
+ class?: string
+ nameClass?: string
+ versionClass?: string
+ dimmed?: boolean
+ badge?: JSXElement
+}
+
+export function ServerRow(props: ServerRowProps) {
+ const [truncated, setTruncated] = createSignal(false)
+ let nameRef: HTMLSpanElement | undefined
+ let versionRef: HTMLSpanElement | undefined
+
+ const check = () => {
+ const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
+ const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
+ setTruncated(nameTruncated || versionTruncated)
+ }
+
+ createEffect(() => {
+ props.url
+ props.status?.version
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(check)
+ return
+ }
+ check()
+ })
+
+ onMount(() => {
+ check()
+ if (typeof window === "undefined") return
+ window.addEventListener("resize", check)
+ onCleanup(() => window.removeEventListener("resize", check))
+ })
+
+ const tooltipValue = () => (
+ <span class="flex items-center gap-2">
+ <span>{serverDisplayName(props.url)}</span>
+ <Show when={props.status?.version}>
+ <span class="text-text-invert-base">{props.status?.version}</span>
+ </Show>
+ </span>
+ )
+
+ return (
+ <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
+ <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
+ <div
+ classList={{
+ "size-1.5 rounded-full shrink-0": true,
+ "bg-icon-success-base": props.status?.healthy === true,
+ "bg-icon-critical-base": props.status?.healthy === false,
+ "bg-border-weak-base": props.status === undefined,
+ }}
+ />
+ <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+ {serverDisplayName(props.url)}
+ </span>
+ <Show when={props.status?.version}>
+ <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+ {props.status?.version}
+ </span>
+ </Show>
+ {props.badge}
+ {props.children}
+ </div>
+ </Tooltip>
+ )
+}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 102c477a1..3354c3d36 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
-
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
- .catch(() => ({ healthy: false }))
-}
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
export function StatusPopover() {
const sync = useSync()
@@ -42,10 +27,11 @@ export function StatusPopover() {
const navigate = useNavigate()
const [store, setStore] = createStore({
- status: {} as Record<string, ServerStatus | undefined>,
+ status: {} as Record<string, ServerHealth | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
+ const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
@@ -60,7 +46,7 @@ export function StatusPopover() {
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerStatus) => {
+ const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
@@ -75,10 +61,10 @@ export function StatusPopover() {
})
async function refreshHealth() {
- const results: Record<string, ServerStatus> = {}
+ const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
- results[url] = await checkHealth(url, platform)
+ results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -213,78 +199,43 @@ export function StatusPopover() {
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
- const [truncated, setTruncated] = createSignal(false)
- let nameRef: HTMLSpanElement | undefined
- let versionRef: HTMLSpanElement | undefined
-
- onMount(() => {
- const check = () => {
- const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
- const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
- setTruncated(nameTruncated || versionTruncated)
- }
- check()
- window.addEventListener("resize", check)
- onCleanup(() => window.removeEventListener("resize", check))
- })
-
- const tooltipValue = () => {
- const name = serverDisplayName(url)
- const version = status()?.version
- return (
- <span class="flex items-center gap-2">
- <span>{name}</span>
- <Show when={version}>
- <span class="text-text-invert-base">{version}</span>
- </Show>
- </span>
- )
- }
return (
- <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
- classList={{
- "opacity-50": isBlocked(),
- "hover:bg-surface-raised-base-hover": !isBlocked(),
- "cursor-not-allowed": isBlocked(),
- }}
- aria-disabled={isBlocked()}
- onClick={() => {
- if (isBlocked()) return
- server.setActive(url)
- navigate("/")
- }}
+ <button
+ type="button"
+ class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+ classList={{
+ "hover:bg-surface-raised-base-hover": !isBlocked(),
+ "cursor-not-allowed": isBlocked(),
+ }}
+ aria-disabled={isBlocked()}
+ onClick={() => {
+ if (isBlocked()) return
+ server.setActive(url)
+ navigate("/")
+ }}
+ >
+ <ServerRow
+ url={url}
+ status={status()}
+ dimmed={isBlocked()}
+ class="flex items-center gap-2 w-full min-w-0"
+ nameClass="text-14-regular text-text-base truncate"
+ versionClass="text-12-regular text-text-weak truncate"
+ badge={
+ <Show when={isDefault()}>
+ <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+ {language.t("common.default")}
+ </span>
+ </Show>
+ }
>
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": status()?.healthy === true,
- "bg-icon-critical-base": status()?.healthy === false,
- "bg-border-weak-base": status() === undefined,
- }}
- />
- <span ref={nameRef} class="text-14-regular text-text-base truncate">
- {serverDisplayName(url)}
- </span>
- <Show when={status()?.version}>
- <span ref={versionRef} class="text-12-regular text-text-weak truncate">
- {status()?.version}
- </span>
- </Show>
- <Show when={isDefault()}>
- <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
- {language.t("common.default")}
- </span>
- </Show>
<div class="flex-1" />
<Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
- </button>
- </Tooltip>
+ </ServerRow>
+ </button>
)
}}
</For>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 4d44d5f7e..2ee2e074e 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
+import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => {
const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return
- const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
- if (!setOption) return
- setOption("theme", colors)
+ setOptionIfSupported(term, "theme", colors)
})
createEffect(() => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
- const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
- if (!setOption) return
- setOption("fontFamily", font)
+ setOptionIfSupported(term, "fontFamily", font)
})
const focusTerminal = () => {
@@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => {
const t = term
if (!t) return
- const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
- if (!link?.text) return
+ const text = getHoveredLinkText(t)
+ if (!text) return
event.preventDefault()
event.stopImmediatePropagation()
- platform.openLink(link.text)
+ platform.openLink(text)
}
onMount(() => {
@@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => {
const fit = new mod.FitAddon()
const serializer = new SerializeAddon()
- cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(fit))
t.loadAddon(serializer)
t.loadAddon(fit)
fitAddon = fit
@@ -303,19 +300,19 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
- cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
- cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
- cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onKey))
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts
new file mode 100644
index 000000000..4e38efd8d
--- /dev/null
+++ b/packages/app/src/context/command-keybind.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { formatKeybind, matchKeybind, parseKeybind } from "./command"
+
+describe("command keybind helpers", () => {
+ test("parseKeybind handles aliases and multiple combos", () => {
+ const keybinds = parseKeybind("control+option+k, mod+shift+comma")
+
+ expect(keybinds).toHaveLength(2)
+ expect(keybinds[0]).toEqual({
+ key: "k",
+ ctrl: true,
+ meta: false,
+ shift: false,
+ alt: true,
+ })
+ expect(keybinds[1]?.shift).toBe(true)
+ expect(keybinds[1]?.key).toBe("comma")
+ expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
+ })
+
+ test("parseKeybind treats none and empty as disabled", () => {
+ expect(parseKeybind("none")).toEqual([])
+ expect(parseKeybind("")).toEqual([])
+ })
+
+ test("matchKeybind normalizes punctuation keys", () => {
+ const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
+
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
+ })
+
+ test("formatKeybind returns human readable output", () => {
+ const display = formatKeybind("ctrl+alt+arrowup")
+
+ expect(display).toContain("↑")
+ expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
+ expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
+ expect(formatKeybind("none")).toBe("")
+ })
+})
diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts
index 9a455e2af..4ef5f947c 100644
--- a/packages/app/src/context/file-content-eviction-accounting.test.ts
+++ b/packages/app/src/context/file-content-eviction-accounting.test.ts
@@ -1,33 +1,13 @@
-import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
-
-let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
-let getFileContentBytesTotal: () => number
-let getFileContentEntryCount: () => number
-let removeFileContentBytes: (path: string) => void
-let resetFileContentLru: () => void
-let setFileContentBytes: (path: string, bytes: number) => void
-let touchFileContent: (path: string, bytes?: number) => void
-
-beforeAll(async () => {
- mock.module("@solidjs/router", () => ({
- useParams: () => ({}),
- }))
- mock.module("@opencode-ai/ui/context", () => ({
- createSimpleContext: () => ({
- use: () => undefined,
- provider: () => undefined,
- }),
- }))
-
- const mod = await import("./file")
- evictContentLru = mod.evictContentLru
- getFileContentBytesTotal = mod.getFileContentBytesTotal
- getFileContentEntryCount = mod.getFileContentEntryCount
- removeFileContentBytes = mod.removeFileContentBytes
- resetFileContentLru = mod.resetFileContentLru
- setFileContentBytes = mod.setFileContentBytes
- touchFileContent = mod.touchFileContent
-})
+import { afterEach, describe, expect, test } from "bun:test"
+import {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
describe("file content eviction accounting", () => {
afterEach(() => {
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 164da726f..996ea2aaf 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -1,324 +1,45 @@
-import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
-import { Persist, persisted } from "@/utils/persist"
-import { createScopedCache } from "@/utils/scoped-cache"
-
-export type FileSelection = {
- startLine: number
- startChar: number
- endLine: number
- endChar: number
-}
-
-export type SelectedLineRange = {
- start: number
- end: number
- side?: "additions" | "deletions"
- endSide?: "additions" | "deletions"
-}
-
-export type FileViewState = {
- scrollTop?: number
- scrollLeft?: number
- selectedLines?: SelectedLineRange | null
-}
-
-export type FileState = {
- path: string
- name: string
- loaded?: boolean
- loading?: boolean
- error?: string
- content?: FileContent
-}
-
-type DirectoryState = {
- expanded: boolean
- loaded?: boolean
- loading?: boolean
- error?: string
- children?: string[]
-}
-
-function stripFileProtocol(input: string) {
- if (!input.startsWith("file://")) return input
- return input.slice("file://".length)
-}
-
-function stripQueryAndHash(input: string) {
- const hashIndex = input.indexOf("#")
- const queryIndex = input.indexOf("?")
-
- if (hashIndex !== -1 && queryIndex !== -1) {
- return input.slice(0, Math.min(hashIndex, queryIndex))
- }
-
- if (hashIndex !== -1) return input.slice(0, hashIndex)
- if (queryIndex !== -1) return input.slice(0, queryIndex)
- return input
-}
-
-function unquoteGitPath(input: string) {
- if (!input.startsWith('"')) return input
- if (!input.endsWith('"')) return input
- const body = input.slice(1, -1)
- const bytes: number[] = []
-
- for (let i = 0; i < body.length; i++) {
- const char = body[i]!
- if (char !== "\\") {
- bytes.push(char.charCodeAt(0))
- continue
- }
-
- const next = body[i + 1]
- if (!next) {
- bytes.push("\\".charCodeAt(0))
- continue
- }
-
- if (next >= "0" && next <= "7") {
- const chunk = body.slice(i + 1, i + 4)
- const match = chunk.match(/^[0-7]{1,3}/)
- if (!match) {
- bytes.push(next.charCodeAt(0))
- i++
- continue
- }
- bytes.push(parseInt(match[0], 8))
- i += match[0].length
- continue
- }
-
- const escaped =
- next === "n"
- ? "\n"
- : next === "r"
- ? "\r"
- : next === "t"
- ? "\t"
- : next === "b"
- ? "\b"
- : next === "f"
- ? "\f"
- : next === "v"
- ? "\v"
- : next === "\\" || next === '"'
- ? next
- : undefined
-
- bytes.push((escaped ?? next).charCodeAt(0))
- i++
- }
-
- return new TextDecoder().decode(new Uint8Array(bytes))
-}
-
-export function selectionFromLines(range: SelectedLineRange): FileSelection {
- const startLine = Math.min(range.start, range.end)
- const endLine = Math.max(range.start, range.end)
- return {
- startLine,
- endLine,
- startChar: 0,
- endChar: 0,
- }
-}
-
-function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
- if (range.start <= range.end) return range
-
- const startSide = range.side
- const endSide = range.endSide ?? startSide
-
- return {
- ...range,
- start: range.end,
- end: range.start,
- side: endSide,
- endSide: startSide !== endSide ? startSide : undefined,
- }
-}
-
-const WORKSPACE_KEY = "__workspace__"
-const MAX_FILE_VIEW_SESSIONS = 20
-const MAX_VIEW_FILES = 500
-
-const MAX_FILE_CONTENT_ENTRIES = 40
-const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
-
-const contentLru = new Map<string, number>()
-let contentBytesTotal = 0
-
-function approxBytes(content: FileContent) {
- const patchBytes =
- content.patch?.hunks.reduce((total, hunk) => {
- return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
- }, 0) ?? 0
-
- return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
-}
-
-function setContentBytes(path: string, nextBytes: number) {
- const prev = contentLru.get(path)
- if (prev !== undefined) contentBytesTotal -= prev
- contentLru.delete(path)
- contentLru.set(path, nextBytes)
- contentBytesTotal += nextBytes
-}
-
-function touchContent(path: string, bytes?: number) {
- const prev = contentLru.get(path)
- if (prev === undefined && bytes === undefined) return
- setContentBytes(path, bytes ?? prev ?? 0)
-}
-
-function removeContentBytes(path: string) {
- const prev = contentLru.get(path)
- if (prev === undefined) return
- contentLru.delete(path)
- contentBytesTotal -= prev
-}
-
-function resetContentBytes() {
- contentLru.clear()
- contentBytesTotal = 0
-}
-
-export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
- const protectedSet = keep ?? new Set<string>()
-
- while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
- const path = contentLru.keys().next().value
- if (!path) return
-
- if (protectedSet.has(path)) {
- touchContent(path)
- if (contentLru.size <= protectedSet.size) return
- continue
- }
-
- removeContentBytes(path)
- evict(path)
- }
-}
-
-export function resetFileContentLru() {
- resetContentBytes()
-}
-
-export function setFileContentBytes(path: string, bytes: number) {
- setContentBytes(path, bytes)
-}
-
-export function removeFileContentBytes(path: string) {
- removeContentBytes(path)
-}
-
-export function touchFileContent(path: string, bytes?: number) {
- touchContent(path, bytes)
-}
-
-export function getFileContentBytesTotal() {
- return contentBytesTotal
-}
-
-export function getFileContentEntryCount() {
- return contentLru.size
-}
-
-function createViewSession(dir: string, id: string | undefined) {
- const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
-
- const [view, setView, _, ready] = persisted(
- Persist.scoped(dir, id, "file-view", [legacyViewKey]),
- createStore<{
- file: Record<string, FileViewState>
- }>({
- file: {},
- }),
- )
-
- const meta = { pruned: false }
-
- const pruneView = (keep?: string) => {
- const keys = Object.keys(view.file)
- if (keys.length <= MAX_VIEW_FILES) return
-
- const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
- if (drop.length === 0) return
-
- setView(
- produce((draft) => {
- for (const key of drop) {
- delete draft.file[key]
- }
- }),
- )
- }
-
- createEffect(() => {
- if (!ready()) return
- if (meta.pruned) return
- meta.pruned = true
- pruneView()
- })
-
- const scrollTop = (path: string) => view.file[path]?.scrollTop
- const scrollLeft = (path: string) => view.file[path]?.scrollLeft
- const selectedLines = (path: string) => view.file[path]?.selectedLines
-
- const setScrollTop = (path: string, top: number) => {
- setView("file", path, (current) => {
- if (current?.scrollTop === top) return current
- return {
- ...(current ?? {}),
- scrollTop: top,
- }
- })
- pruneView(path)
- }
-
- const setScrollLeft = (path: string, left: number) => {
- setView("file", path, (current) => {
- if (current?.scrollLeft === left) return current
- return {
- ...(current ?? {}),
- scrollLeft: left,
- }
- })
- pruneView(path)
- }
-
- const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
- const next = range ? normalizeSelectedLines(range) : null
- setView("file", path, (current) => {
- if (current?.selectedLines === next) return current
- return {
- ...(current ?? {}),
- selectedLines: next,
- }
- })
- pruneView(path)
- }
-
- return {
- ready,
- scrollTop,
- scrollLeft,
- selectedLines,
- setScrollTop,
- setScrollLeft,
- setSelectedLines,
- }
+import { createPathHelpers } from "./file/path"
+import {
+ approxBytes,
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ hasFileContent,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
+import { createFileViewCache } from "./file/view-cache"
+import { createFileTreeStore } from "./file/tree-store"
+import { invalidateFromWatcher } from "./file/watcher"
+import {
+ selectionFromLines,
+ type FileState,
+ type FileSelection,
+ type FileViewState,
+ type SelectedLineRange,
+} from "./file/types"
+
+export type { FileSelection, SelectedLineRange, FileViewState, FileState }
+export { selectionFromLines }
+export {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -326,76 +47,39 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
gate: false,
init: () => {
const sdk = useSDK()
- const sync = useSync()
+ useSync()
const params = useParams()
const language = useLanguage()
const scope = createMemo(() => sdk.directory)
-
- function normalize(input: string) {
- const root = scope()
- const prefix = root.endsWith("/") ? root : root + "/"
-
- let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
-
- if (path.startsWith(prefix)) {
- path = path.slice(prefix.length)
- }
-
- if (path.startsWith(root)) {
- path = path.slice(root.length)
- }
-
- if (path.startsWith("./")) {
- path = path.slice(2)
- }
-
- if (path.startsWith("/")) {
- path = path.slice(1)
- }
-
- return path
- }
-
- function tab(input: string) {
- const path = normalize(input)
- return `file://${path}`
- }
-
- function pathFromTab(tabValue: string) {
- if (!tabValue.startsWith("file://")) return
- return normalize(tabValue)
- }
+ const path = createPathHelpers(scope)
const inflight = new Map<string, Promise<void>>()
- const treeInflight = new Map<string, Promise<void>>()
-
- const search = (query: string, dirs: "true" | "false") =>
- sdk.client.find.files({ query, dirs }).then(
- (x) => (x.data ?? []).map(normalize),
- () => [],
- )
-
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
file: {},
})
- const [tree, setTree] = createStore<{
- node: Record<string, FileNode>
- dir: Record<string, DirectoryState>
- }>({
- node: {},
- dir: { "": { expanded: true } },
+ const tree = createFileTreeStore({
+ scope,
+ normalizeDir: path.normalizeDir,
+ list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
+ onError: (message) => {
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.listFailed.title"),
+ description: message,
+ })
+ },
})
const evictContent = (keep?: Set<string>) => {
- evictContentLru(keep, (path) => {
- if (!store.file[path]) return
+ evictContentLru(keep, (target) => {
+ if (!store.file[target]) return
setStore(
"file",
- path,
+ target,
produce((draft) => {
draft.content = undefined
draft.loaded = false
@@ -407,57 +91,31 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
createEffect(() => {
scope()
inflight.clear()
- treeInflight.clear()
- resetContentBytes()
-
+ resetFileContentLru()
batch(() => {
setStore("file", reconcile({}))
- setTree("node", reconcile({}))
- setTree("dir", reconcile({}))
- setTree("dir", "", { expanded: true })
+ tree.reset()
})
})
- const viewCache = createScopedCache(
- (key) => {
- const split = key.lastIndexOf("\n")
- const dir = split >= 0 ? key.slice(0, split) : key
- const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
- return createRoot((dispose) => ({
- value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
- dispose,
- }))
- },
- {
- maxEntries: MAX_FILE_VIEW_SESSIONS,
- dispose: (entry) => entry.dispose(),
- },
- )
-
- const loadView = (dir: string, id: string | undefined) => {
- const key = `${dir}\n${id ?? WORKSPACE_KEY}`
- return viewCache.get(key).value
- }
-
- const view = createMemo(() => loadView(scope(), params.id))
+ const viewCache = createFileViewCache()
+ const view = createMemo(() => viewCache.load(scope(), params.id))
- function ensure(path: string) {
- if (!path) return
- if (store.file[path]) return
- setStore("file", path, { path, name: getFilename(path) })
+ const ensure = (file: string) => {
+ if (!file) return
+ if (store.file[file]) return
+ setStore("file", file, { path: file, name: getFilename(file) })
}
- function load(input: string, options?: { force?: boolean }) {
- const path = normalize(input)
- if (!path) return Promise.resolve()
+ const load = (input: string, options?: { force?: boolean }) => {
+ const file = path.normalize(input)
+ if (!file) return Promise.resolve()
const directory = scope()
- const key = `${directory}\n${path}`
- const client = sdk.client
-
- ensure(path)
+ const key = `${directory}\n${file}`
+ ensure(file)
- const current = store.file[path]
+ const current = store.file[file]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(key)
@@ -465,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
- const promise = client.file
- .read({ path })
+ const promise = sdk.client.file
+ .read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loaded = true
draft.loading = false
@@ -488,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
)
if (!content) return
- touchContent(path, approxBytes(content))
- evictContent(new Set([path]))
+ touchFileContent(file, approxBytes(content))
+ evictContent(new Set([file]))
})
.catch((e) => {
if (scope() !== directory) return
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = false
draft.error = e.message
@@ -515,200 +173,54 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
- function normalizeDir(input: string) {
- return normalize(input).replace(/\/+$/, "")
- }
-
- function ensureDir(path: string) {
- if (tree.dir[path]) return
- setTree("dir", path, { expanded: false })
- }
-
- function listDir(input: string, options?: { force?: boolean }) {
- const dir = normalizeDir(input)
- ensureDir(dir)
-
- const current = tree.dir[dir]
- if (!options?.force && current?.loaded) return Promise.resolve()
-
- const pending = treeInflight.get(dir)
- if (pending) return pending
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = true
- draft.error = undefined
- }),
+ const search = (query: string, dirs: "true" | "false") =>
+ sdk.client.find.files({ query, dirs }).then(
+ (x) => (x.data ?? []).map(path.normalize),
+ () => [],
)
- const directory = scope()
-
- const promise = sdk.client.file
- .list({ path: dir })
- .then((x) => {
- if (scope() !== directory) return
- const nodes = x.data ?? []
- const prevChildren = tree.dir[dir]?.children ?? []
- const nextChildren = nodes.map((node) => node.path)
- const nextSet = new Set(nextChildren)
-
- setTree(
- "node",
- produce((draft) => {
- const removedDirs: string[] = []
-
- for (const child of prevChildren) {
- if (nextSet.has(child)) continue
- const existing = draft[child]
- if (existing?.type === "directory") removedDirs.push(child)
- delete draft[child]
- }
-
- if (removedDirs.length > 0) {
- const keys = Object.keys(draft)
- for (const key of keys) {
- for (const removed of removedDirs) {
- if (!key.startsWith(removed + "/")) continue
- delete draft[key]
- break
- }
- }
- }
-
- for (const node of nodes) {
- draft[node.path] = node
- }
- }),
- )
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loaded = true
- draft.loading = false
- draft.children = nextChildren
- }),
- )
- })
- .catch((e) => {
- if (scope() !== directory) return
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = false
- draft.error = e.message
- }),
- )
- showToast({
- variant: "error",
- title: language.t("toast.file.listFailed.title"),
- description: e.message,
- })
- })
- .finally(() => {
- treeInflight.delete(dir)
- })
-
- treeInflight.set(dir, promise)
- return promise
- }
-
- function expandDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", true)
- void listDir(dir)
- }
-
- function collapseDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", false)
- }
-
- function dirState(input: string) {
- const dir = normalizeDir(input)
- return tree.dir[dir]
- }
-
- function children(input: string) {
- const dir = normalizeDir(input)
- const ids = tree.dir[dir]?.children
- if (!ids) return []
- const out: FileNode[] = []
- for (const id of ids) {
- const node = tree.node[id]
- if (node) out.push(node)
- }
- return out
- }
-
const stop = sdk.event.listen((e) => {
- const event = e.details
- if (event.type !== "file.watcher.updated") return
- const path = normalize(event.properties.file)
- if (!path) return
- if (path.startsWith(".git/")) return
-
- if (store.file[path]) {
- load(path, { force: true })
- }
-
- const kind = event.properties.event
- if (kind === "change") {
- const dir = (() => {
- if (path === "") return ""
- const node = tree.node[path]
- if (node?.type !== "directory") return
- return path
- })()
- if (dir === undefined) return
- if (!tree.dir[dir]?.loaded) return
- listDir(dir, { force: true })
- return
- }
- if (kind !== "add" && kind !== "unlink") return
-
- const parent = path.split("/").slice(0, -1).join("/")
- if (!tree.dir[parent]?.loaded) return
-
- listDir(parent, { force: true })
+ invalidateFromWatcher(e.details, {
+ normalize: path.normalize,
+ hasFile: (file) => Boolean(store.file[file]),
+ loadFile: (file) => {
+ void load(file, { force: true })
+ },
+ node: tree.node,
+ isDirLoaded: tree.isLoaded,
+ refreshDir: (dir) => {
+ void tree.listDir(dir, { force: true })
+ },
+ })
})
const get = (input: string) => {
- const path = normalize(input)
- const file = store.file[path]
- const content = file?.content
- if (!content) return file
- if (contentLru.has(path)) {
- touchContent(path)
- return file
+ const file = path.normalize(input)
+ const state = store.file[file]
+ const content = state?.content
+ if (!content) return state
+ if (hasFileContent(file)) {
+ touchFileContent(file)
+ return state
}
- touchContent(path, approxBytes(content))
- return file
+ touchFileContent(file, approxBytes(content))
+ return state
}
- const scrollTop = (input: string) => view().scrollTop(normalize(input))
- const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
- const selectedLines = (input: string) => view().selectedLines(normalize(input))
+ const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
+ const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
+ const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => {
- const path = normalize(input)
- view().setScrollTop(path, top)
+ view().setScrollTop(path.normalize(input), top)
}
const setScrollLeft = (input: string, left: number) => {
- const path = normalize(input)
- view().setScrollLeft(path, left)
+ view().setScrollLeft(path.normalize(input), left)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
- const path = normalize(input)
- view().setSelectedLines(path, range)
+ view().setSelectedLines(path.normalize(input), range)
}
onCleanup(() => {
@@ -718,22 +230,22 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
- normalize,
- tab,
- pathFromTab,
+ normalize: path.normalize,
+ tab: path.tab,
+ pathFromTab: path.pathFromTab,
tree: {
- list: listDir,
- refresh: (input: string) => listDir(input, { force: true }),
- state: dirState,
- children,
- expand: expandDir,
- collapse: collapseDir,
+ list: tree.listDir,
+ refresh: (input: string) => tree.listDir(input, { force: true }),
+ state: tree.dirState,
+ children: tree.children,
+ expand: tree.expandDir,
+ collapse: tree.collapseDir,
toggle(input: string) {
- if (dirState(input)?.expanded) {
- collapseDir(input)
+ if (tree.dirState(input)?.expanded) {
+ tree.collapseDir(input)
return
}
- expandDir(input)
+ tree.expandDir(input)
},
},
get,
diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts
new file mode 100644
index 000000000..4b7240688
--- /dev/null
+++ b/packages/app/src/context/file/content-cache.ts
@@ -0,0 +1,88 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const lru = new Map<string, number>()
+let total = 0
+
+export function approxBytes(content: FileContent) {
+ const patchBytes =
+ content.patch?.hunks.reduce((sum, hunk) => {
+ return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
+ }, 0) ?? 0
+
+ return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function setBytes(path: string, nextBytes: number) {
+ const prev = lru.get(path)
+ if (prev !== undefined) total -= prev
+ lru.delete(path)
+ lru.set(path, nextBytes)
+ total += nextBytes
+}
+
+function touch(path: string, bytes?: number) {
+ const prev = lru.get(path)
+ if (prev === undefined && bytes === undefined) return
+ setBytes(path, bytes ?? prev ?? 0)
+}
+
+function remove(path: string) {
+ const prev = lru.get(path)
+ if (prev === undefined) return
+ lru.delete(path)
+ total -= prev
+}
+
+function reset() {
+ lru.clear()
+ total = 0
+}
+
+export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
+ const set = keep ?? new Set<string>()
+
+ while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
+ const path = lru.keys().next().value
+ if (!path) return
+
+ if (set.has(path)) {
+ touch(path)
+ if (lru.size <= set.size) return
+ continue
+ }
+
+ remove(path)
+ evict(path)
+ }
+}
+
+export function resetFileContentLru() {
+ reset()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+ setBytes(path, bytes)
+}
+
+export function removeFileContentBytes(path: string) {
+ remove(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+ touch(path, bytes)
+}
+
+export function getFileContentBytesTotal() {
+ return total
+}
+
+export function getFileContentEntryCount() {
+ return lru.size
+}
+
+export function hasFileContent(path: string) {
+ return lru.has(path)
+}
diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts
new file mode 100644
index 000000000..dba9ae06d
--- /dev/null
+++ b/packages/app/src/context/file/path.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test"
+import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
+
+describe("file path helpers", () => {
+ test("normalizes file inputs against workspace root", () => {
+ const path = createPathHelpers(() => "/repo")
+ expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
+ expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
+ expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
+ expect(path.normalizeDir("src/components///")).toBe("src/components")
+ expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
+ expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
+ expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
+ })
+
+ test("keeps query/hash stripping behavior stable", () => {
+ expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
+ })
+
+ test("unquotes git escaped octal path strings", () => {
+ expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
+ expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
+ expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
+ })
+})
diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts
new file mode 100644
index 000000000..ced30d0fd
--- /dev/null
+++ b/packages/app/src/context/file/path.ts
@@ -0,0 +1,119 @@
+export function stripFileProtocol(input: string) {
+ if (!input.startsWith("file://")) return input
+ return input.slice("file://".length)
+}
+
+export function stripQueryAndHash(input: string) {
+ const hashIndex = input.indexOf("#")
+ const queryIndex = input.indexOf("?")
+
+ if (hashIndex !== -1 && queryIndex !== -1) {
+ return input.slice(0, Math.min(hashIndex, queryIndex))
+ }
+
+ if (hashIndex !== -1) return input.slice(0, hashIndex)
+ if (queryIndex !== -1) return input.slice(0, queryIndex)
+ return input
+}
+
+export function unquoteGitPath(input: string) {
+ if (!input.startsWith('"')) return input
+ if (!input.endsWith('"')) return input
+ const body = input.slice(1, -1)
+ const bytes: number[] = []
+
+ for (let i = 0; i < body.length; i++) {
+ const char = body[i]!
+ if (char !== "\\") {
+ bytes.push(char.charCodeAt(0))
+ continue
+ }
+
+ const next = body[i + 1]
+ if (!next) {
+ bytes.push("\\".charCodeAt(0))
+ continue
+ }
+
+ if (next >= "0" && next <= "7") {
+ const chunk = body.slice(i + 1, i + 4)
+ const match = chunk.match(/^[0-7]{1,3}/)
+ if (!match) {
+ bytes.push(next.charCodeAt(0))
+ i++
+ continue
+ }
+ bytes.push(parseInt(match[0], 8))
+ i += match[0].length
+ continue
+ }
+
+ const escaped =
+ next === "n"
+ ? "\n"
+ : next === "r"
+ ? "\r"
+ : next === "t"
+ ? "\t"
+ : next === "b"
+ ? "\b"
+ : next === "f"
+ ? "\f"
+ : next === "v"
+ ? "\v"
+ : next === "\\" || next === '"'
+ ? next
+ : undefined
+
+ bytes.push((escaped ?? next).charCodeAt(0))
+ i++
+ }
+
+ return new TextDecoder().decode(new Uint8Array(bytes))
+}
+
+export function createPathHelpers(scope: () => string) {
+ const normalize = (input: string) => {
+ const root = scope()
+ const prefix = root.endsWith("/") ? root : root + "/"
+
+ let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
+
+ if (path.startsWith(prefix)) {
+ path = path.slice(prefix.length)
+ }
+
+ if (path.startsWith(root)) {
+ path = path.slice(root.length)
+ }
+
+ if (path.startsWith("./")) {
+ path = path.slice(2)
+ }
+
+ if (path.startsWith("/")) {
+ path = path.slice(1)
+ }
+
+ return path
+ }
+
+ const tab = (input: string) => {
+ const path = normalize(input)
+ return `file://${path}`
+ }
+
+ const pathFromTab = (tabValue: string) => {
+ if (!tabValue.startsWith("file://")) return
+ return normalize(tabValue)
+ }
+
+ const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
+
+ return {
+ normalize,
+ tab,
+ pathFromTab,
+ normalizeDir,
+ }
+}
diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts
new file mode 100644
index 000000000..a86051d28
--- /dev/null
+++ b/packages/app/src/context/file/tree-store.ts
@@ -0,0 +1,170 @@
+import { createStore, produce, reconcile } from "solid-js/store"
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type DirectoryState = {
+ expanded: boolean
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ children?: string[]
+}
+
+type TreeStoreOptions = {
+ scope: () => string
+ normalizeDir: (input: string) => string
+ list: (input: string) => Promise<FileNode[]>
+ onError: (message: string) => void
+}
+
+export function createFileTreeStore(options: TreeStoreOptions) {
+ const [tree, setTree] = createStore<{
+ node: Record<string, FileNode>
+ dir: Record<string, DirectoryState>
+ }>({
+ node: {},
+ dir: { "": { expanded: true } },
+ })
+
+ const inflight = new Map<string, Promise<void>>()
+
+ const reset = () => {
+ inflight.clear()
+ setTree("node", reconcile({}))
+ setTree("dir", reconcile({}))
+ setTree("dir", "", { expanded: true })
+ }
+
+ const ensureDir = (path: string) => {
+ if (tree.dir[path]) return
+ setTree("dir", path, { expanded: false })
+ }
+
+ const listDir = (input: string, opts?: { force?: boolean }) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+
+ const current = tree.dir[dir]
+ if (!opts?.force && current?.loaded) return Promise.resolve()
+
+ const pending = inflight.get(dir)
+ if (pending) return pending
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = true
+ draft.error = undefined
+ }),
+ )
+
+ const directory = options.scope()
+
+ const promise = options
+ .list(dir)
+ .then((nodes) => {
+ if (options.scope() !== directory) return
+ const prevChildren = tree.dir[dir]?.children ?? []
+ const nextChildren = nodes.map((node) => node.path)
+ const nextSet = new Set(nextChildren)
+
+ setTree(
+ "node",
+ produce((draft) => {
+ const removedDirs: string[] = []
+
+ for (const child of prevChildren) {
+ if (nextSet.has(child)) continue
+ const existing = draft[child]
+ if (existing?.type === "directory") removedDirs.push(child)
+ delete draft[child]
+ }
+
+ if (removedDirs.length > 0) {
+ const keys = Object.keys(draft)
+ for (const key of keys) {
+ for (const removed of removedDirs) {
+ if (!key.startsWith(removed + "/")) continue
+ delete draft[key]
+ break
+ }
+ }
+ }
+
+ for (const node of nodes) {
+ draft[node.path] = node
+ }
+ }),
+ )
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loaded = true
+ draft.loading = false
+ draft.children = nextChildren
+ }),
+ )
+ })
+ .catch((e) => {
+ if (options.scope() !== directory) return
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = false
+ draft.error = e.message
+ }),
+ )
+ options.onError(e.message)
+ })
+ .finally(() => {
+ inflight.delete(dir)
+ })
+
+ inflight.set(dir, promise)
+ return promise
+ }
+
+ const expandDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", true)
+ void listDir(dir)
+ }
+
+ const collapseDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", false)
+ }
+
+ const dirState = (input: string) => {
+ const dir = options.normalizeDir(input)
+ return tree.dir[dir]
+ }
+
+ const children = (input: string) => {
+ const dir = options.normalizeDir(input)
+ const ids = tree.dir[dir]?.children
+ if (!ids) return []
+ const out: FileNode[] = []
+ for (const id of ids) {
+ const node = tree.node[id]
+ if (node) out.push(node)
+ }
+ return out
+ }
+
+ return {
+ listDir,
+ expandDir,
+ collapseDir,
+ dirState,
+ children,
+ node: (path: string) => tree.node[path],
+ isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
+ reset,
+ }
+}
diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts
new file mode 100644
index 000000000..7ce8a37c2
--- /dev/null
+++ b/packages/app/src/context/file/types.ts
@@ -0,0 +1,41 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type FileSelection = {
+ startLine: number
+ startChar: number
+ endLine: number
+ endChar: number
+}
+
+export type SelectedLineRange = {
+ start: number
+ end: number
+ side?: "additions" | "deletions"
+ endSide?: "additions" | "deletions"
+}
+
+export type FileViewState = {
+ scrollTop?: number
+ scrollLeft?: number
+ selectedLines?: SelectedLineRange | null
+}
+
+export type FileState = {
+ path: string
+ name: string
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ content?: FileContent
+}
+
+export function selectionFromLines(range: SelectedLineRange): FileSelection {
+ const startLine = Math.min(range.start, range.end)
+ const endLine = Math.max(range.start, range.end)
+ return {
+ startLine,
+ endLine,
+ startChar: 0,
+ endChar: 0,
+ }
+}
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
new file mode 100644
index 000000000..2614b2fb5
--- /dev/null
+++ b/packages/app/src/context/file/view-cache.ts
@@ -0,0 +1,136 @@
+import { createEffect, createRoot } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
+import type { FileViewState, SelectedLineRange } from "./types"
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_FILE_VIEW_SESSIONS = 20
+const MAX_VIEW_FILES = 500
+
+function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
+ if (range.start <= range.end) return range
+
+ const startSide = range.side
+ const endSide = range.endSide ?? startSide
+
+ return {
+ ...range,
+ start: range.end,
+ end: range.start,
+ side: endSide,
+ endSide: startSide !== endSide ? startSide : undefined,
+ }
+}
+
+function createViewSession(dir: string, id: string | undefined) {
+ const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
+
+ const [view, setView, _, ready] = persisted(
+ Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+ createStore<{
+ file: Record<string, FileViewState>
+ }>({
+ file: {},
+ }),
+ )
+
+ const meta = { pruned: false }
+
+ const pruneView = (keep?: string) => {
+ const keys = Object.keys(view.file)
+ if (keys.length <= MAX_VIEW_FILES) return
+
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+ if (drop.length === 0) return
+
+ setView(
+ produce((draft) => {
+ for (const key of drop) {
+ delete draft.file[key]
+ }
+ }),
+ )
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (meta.pruned) return
+ meta.pruned = true
+ pruneView()
+ })
+
+ const scrollTop = (path: string) => view.file[path]?.scrollTop
+ const scrollLeft = (path: string) => view.file[path]?.scrollLeft
+ const selectedLines = (path: string) => view.file[path]?.selectedLines
+
+ const setScrollTop = (path: string, top: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollTop === top) return current
+ return {
+ ...(current ?? {}),
+ scrollTop: top,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setScrollLeft = (path: string, left: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollLeft === left) return current
+ return {
+ ...(current ?? {}),
+ scrollLeft: left,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
+ const next = range ? normalizeSelectedLines(range) : null
+ setView("file", path, (current) => {
+ if (current?.selectedLines === next) return current
+ return {
+ ...(current ?? {}),
+ selectedLines: next,
+ }
+ })
+ pruneView(path)
+ }
+
+ return {
+ ready,
+ scrollTop,
+ scrollLeft,
+ selectedLines,
+ setScrollTop,
+ setScrollLeft,
+ setSelectedLines,
+ }
+}
+
+export function createFileViewCache() {
+ const cache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_FILE_VIEW_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
+
+ return {
+ load: (dir: string, id: string | undefined) => {
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return cache.get(key).value
+ },
+ clear: () => cache.clear(),
+ }
+}
diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts
new file mode 100644
index 000000000..653e0aa75
--- /dev/null
+++ b/packages/app/src/context/file/watcher.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, test } from "bun:test"
+import { invalidateFromWatcher } from "./watcher"
+
+describe("file watcher invalidation", () => {
+ test("reloads open files and refreshes loaded parent on add", () => {
+ const loads: string[] = []
+ const refresh: string[] = []
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/new.ts",
+ event: "add",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: (path) => path === "src/new.ts",
+ loadFile: (path) => loads.push(path),
+ node: () => undefined,
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(loads).toEqual(["src/new.ts"])
+ expect(refresh).toEqual(["src"])
+ })
+
+ test("refreshes only changed loaded directory nodes", () => {
+ const refresh: string[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/file.ts",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({
+ path: "src/file.ts",
+ type: "file",
+ name: "file.ts",
+ absolute: "/repo/src/file.ts",
+ ignored: false,
+ }),
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual(["src"])
+ })
+
+ test("ignores invalid or git watcher updates", () => {
+ const refresh: string[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: ".git/index.lock",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => true,
+ loadFile: () => {
+ throw new Error("should not load")
+ },
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "project.updated",
+ properties: {},
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual([])
+ })
+})
diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts
new file mode 100644
index 000000000..a3a98eae4
--- /dev/null
+++ b/packages/app/src/context/file/watcher.ts
@@ -0,0 +1,52 @@
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type WatcherEvent = {
+ type: string
+ properties: unknown
+}
+
+type WatcherOps = {
+ normalize: (input: string) => string
+ hasFile: (path: string) => boolean
+ loadFile: (path: string) => void
+ node: (path: string) => FileNode | undefined
+ isDirLoaded: (path: string) => boolean
+ refreshDir: (path: string) => void
+}
+
+export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
+ if (event.type !== "file.watcher.updated") return
+ const props =
+ typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
+ const rawPath = typeof props?.file === "string" ? props.file : undefined
+ const kind = typeof props?.event === "string" ? props.event : undefined
+ if (!rawPath) return
+ if (!kind) return
+
+ const path = ops.normalize(rawPath)
+ if (!path) return
+ if (path.startsWith(".git/")) return
+
+ if (ops.hasFile(path)) {
+ ops.loadFile(path)
+ }
+
+ if (kind === "change") {
+ const dir = (() => {
+ if (path === "") return ""
+ const node = ops.node(path)
+ if (node?.type !== "directory") return
+ return path
+ })()
+ if (dir === undefined) return
+ if (!ops.isDirLoaded(dir)) return
+ ops.refreshDir(dir)
+ return
+ }
+ if (kind !== "add" && kind !== "unlink") return
+
+ const parent = path.split("/").slice(0, -1).join("/")
+ if (!ops.isDirLoaded(parent)) return
+
+ ops.refreshDir(parent)
+}
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 0d6b5dfff..e2bf44980 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -1,41 +1,22 @@
import {
- type Message,
- type Agent,
- type Session,
- type Part,
type Config,
type Path,
type Project,
- type FileDiff,
- type Todo,
- type SessionStatus,
- type ProviderListResponse,
type ProviderAuthResponse,
- type Command,
- type McpStatus,
- type LspStatus,
- type VcsInfo,
- type PermissionRequest,
- type QuestionRequest,
+ type ProviderListResponse,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
-import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
-import { Binary } from "@opencode-ai/util/binary"
-import { retry } from "@opencode-ai/util/retry"
+import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import {
- batch,
createContext,
- createRoot,
createEffect,
untrack,
getOwner,
- runWithOwner,
useContext,
onCleanup,
onMount,
- type Accessor,
type ParentProps,
Switch,
Match,
@@ -45,181 +26,25 @@ import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
-
-type ProjectMeta = {
- name?: string
- icon?: {
- override?: string
- color?: string
- }
- commands?: {
- start?: string
- }
-}
-
-type State = {
- status: "loading" | "partial" | "complete"
- agent: Agent[]
- command: Command[]
- project: string
- projectMeta: ProjectMeta | undefined
- icon: string | undefined
+import { createRefreshQueue } from "./global-sync/queue"
+import { createChildStoreManager } from "./global-sync/child-store"
+import { trimSessions } from "./global-sync/session-trim"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
+import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
+import { sanitizeProject } from "./global-sync/utils"
+import type { ProjectMeta } from "./global-sync/types"
+import { SESSION_RECENT_LIMIT } from "./global-sync/types"
+
+type GlobalStore = {
+ ready: boolean
+ error?: InitError
+ path: Path
+ project: Project[]
provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
config: Config
- path: Path
- session: Session[]
- sessionTotal: number
- session_status: {
- [sessionID: string]: SessionStatus
- }
- session_diff: {
- [sessionID: string]: FileDiff[]
- }
- todo: {
- [sessionID: string]: Todo[]
- }
- permission: {
- [sessionID: string]: PermissionRequest[]
- }
- question: {
- [sessionID: string]: QuestionRequest[]
- }
- mcp: {
- [name: string]: McpStatus
- }
- lsp: LspStatus[]
- vcs: VcsInfo | undefined
- limit: number
- message: {
- [sessionID: string]: Message[]
- }
- part: {
- [messageID: string]: Part[]
- }
-}
-
-type VcsCache = {
- store: Store<{ value: VcsInfo | undefined }>
- setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
- ready: Accessor<boolean>
-}
-
-type MetaCache = {
- store: Store<{ value: ProjectMeta | undefined }>
- setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
- ready: Accessor<boolean>
-}
-
-type IconCache = {
- store: Store<{ value: string | undefined }>
- setStore: SetStoreFunction<{ value: string | undefined }>
- ready: Accessor<boolean>
-}
-
-type ChildOptions = {
- bootstrap?: boolean
-}
-
-const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
-
-function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
- return {
- ...input,
- all: input.all.map((provider) => ({
- ...provider,
- models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
- })),
- }
-}
-
-const MAX_DIR_STORES = 30
-const DIR_IDLE_TTL_MS = 20 * 60 * 1000
-
-type DirState = {
- lastAccessAt: number
-}
-
-type EvictPlan = {
- stores: string[]
- state: Map<string, DirState>
- pins: Set<string>
- max: number
- ttl: number
- now: number
-}
-
-export function pickDirectoriesToEvict(input: EvictPlan) {
- const overflow = Math.max(0, input.stores.length - input.max)
- let pendingOverflow = overflow
- const sorted = input.stores
- .filter((dir) => !input.pins.has(dir))
- .slice()
- .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
-
- const output: string[] = []
- for (const dir of sorted) {
- const last = input.state.get(dir)?.lastAccessAt ?? 0
- const idle = input.now - last >= input.ttl
- if (!idle && pendingOverflow <= 0) continue
- output.push(dir)
- if (pendingOverflow > 0) pendingOverflow -= 1
- }
- return output
-}
-
-type RootLoadArgs = {
- directory: string
- limit: number
- list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
- onFallback: () => void
-}
-
-type RootLoadResult = {
- data?: Session[]
- limit: number
- limited: boolean
-}
-
-export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
- try {
- const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
- return {
- data: result.data,
- limit: input.limit,
- limited: true,
- } satisfies RootLoadResult
- } catch {
- input.onFallback()
- const result = await input.list({ directory: input.directory, roots: true })
- return {
- data: result.data,
- limit: input.limit,
- limited: false,
- } satisfies RootLoadResult
- }
-}
-
-export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
- if (!input.limited) return input.count
- if (input.count < input.limit) return input.count
- return input.count + 1
-}
-
-type DisposeCheck = {
- directory: string
- hasStore: boolean
- pinned: boolean
- booting: boolean
- loadingSessions: boolean
-}
-
-export function canDisposeDirectory(input: DisposeCheck) {
- if (!input.directory) return false
- if (!input.hasStore) return false
- if (input.pinned) return false
- if (input.booting) return false
- if (input.loadingSessions) return false
- return true
+ reload: undefined | "pending" | "complete"
}
function createGlobalSync() {
@@ -228,21 +53,33 @@ function createGlobalSync() {
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
- const vcsCache = new Map<string, VcsCache>()
- const metaCache = new Map<string, MetaCache>()
- const iconCache = new Map<string, IconCache>()
- const lifecycle = new Map<string, DirState>()
- const pins = new Map<string, number>()
- const ownerPins = new WeakMap<object, Set<string>>()
- const disposers = new Map<string, () => void>()
+
const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
+ const booting = new Map<string, Promise<void>>()
+ const sessionLoads = new Map<string, Promise<void>>()
+ const sessionMeta = new Map<string, { limit: number }>()
+
+ const [projectCache, setProjectCache, , projectCacheReady] = persisted(
+ Persist.global("globalSync.project", ["globalSync.project.v1"]),
+ createStore({ value: [] as Project[] }),
+ )
+
+ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
+ ready: false,
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ project: projectCache.value,
+ provider: { all: [], connected: [], default: {} },
+ provider_auth: {},
+ config: {},
+ reload: undefined,
+ })
- const updateStats = () => {
+ const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
;(
globalThis as {
@@ -253,115 +90,42 @@ function createGlobalSync() {
}
}
).__OPENCODE_GLOBAL_SYNC_STATS = {
- activeDirectoryStores: Object.keys(children).length,
+ activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
}
}
- const mark = (directory: string) => {
- if (!directory) return
- lifecycle.set(directory, { lastAccessAt: Date.now() })
- runEviction()
- }
-
- const pin = (directory: string) => {
- if (!directory) return
- pins.set(directory, (pins.get(directory) ?? 0) + 1)
- mark(directory)
- }
-
- const unpin = (directory: string) => {
- if (!directory) return
- const next = (pins.get(directory) ?? 0) - 1
- if (next > 0) {
- pins.set(directory, next)
- return
- }
- pins.delete(directory)
- runEviction()
- }
-
- const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
-
- const pinForOwner = (directory: string) => {
- const current = getOwner()
- if (!current) return
- if (current === owner) return
- const key = current as object
- const set = ownerPins.get(key)
- if (set?.has(directory)) return
- if (set) set.add(directory)
- else ownerPins.set(key, new Set([directory]))
- pin(directory)
- onCleanup(() => {
- const set = ownerPins.get(key)
- if (set) {
- set.delete(directory)
- if (set.size === 0) ownerPins.delete(key)
- }
- unpin(directory)
- })
- }
-
- function disposeDirectory(directory: string) {
- if (
- !canDisposeDirectory({
- directory,
- hasStore: !!children[directory],
- pinned: pinned(directory),
- booting: booting.has(directory),
- loadingSessions: sessionLoads.has(directory),
- })
- ) {
- return false
- }
-
- queued.delete(directory)
- sessionMeta.delete(directory)
- sdkCache.delete(directory)
- vcsCache.delete(directory)
- metaCache.delete(directory)
- iconCache.delete(directory)
- lifecycle.delete(directory)
-
- const dispose = disposers.get(directory)
- if (dispose) {
- dispose()
- disposers.delete(directory)
- }
-
- delete children[directory]
- updateStats()
- return true
- }
+ const paused = () => untrack(() => globalStore.reload) !== undefined
- function runEviction() {
- const stores = Object.keys(children)
- if (stores.length === 0) return
- const list = pickDirectoriesToEvict({
- stores,
- state: lifecycle,
- pins: new Set(stores.filter(pinned)),
- max: MAX_DIR_STORES,
- ttl: DIR_IDLE_TTL_MS,
- now: Date.now(),
- })
+ const queue = createRefreshQueue({
+ paused,
+ bootstrap,
+ bootstrapInstance,
+ })
- if (list.length === 0) return
- let changed = false
- for (const directory of list) {
- if (!disposeDirectory(directory)) continue
+ const children = createChildStoreManager({
+ owner,
+ markStats: updateStats,
+ incrementEvictions: () => {
stats.evictions += 1
- changed = true
- }
- if (changed) updateStats()
- }
+ updateStats(Object.keys(children.children).length)
+ },
+ isBooting: (directory) => booting.has(directory),
+ isLoadingSessions: (directory) => sessionLoads.has(directory),
+ onBootstrap: (directory) => {
+ void bootstrapInstance(directory)
+ },
+ onDispose: (directory) => {
+ queue.clear(directory)
+ sessionMeta.delete(directory)
+ sdkCache.delete(directory)
+ },
+ })
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
-
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
@@ -372,109 +136,6 @@ function createGlobalSync() {
return sdk
}
- const [projectCache, setProjectCache, , projectCacheReady] = persisted(
- Persist.global("globalSync.project", ["globalSync.project.v1"]),
- createStore({ value: [] as Project[] }),
- )
-
- const sanitizeProject = (project: Project) => {
- if (!project.icon?.url && !project.icon?.override) return project
- return {
- ...project,
- icon: {
- ...project.icon,
- url: undefined,
- override: undefined,
- },
- }
- }
- const [globalStore, setGlobalStore] = createStore<{
- ready: boolean
- error?: InitError
- path: Path
- project: Project[]
- provider: ProviderListResponse
- provider_auth: ProviderAuthResponse
- config: Config
- reload: undefined | "pending" | "complete"
- }>({
- ready: false,
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- project: projectCache.value,
- provider: { all: [], connected: [], default: {} },
- provider_auth: {},
- config: {},
- reload: undefined,
- })
-
- const queued = new Set<string>()
- let root = false
- let running = false
- let timer: ReturnType<typeof setTimeout> | undefined
-
- const paused = () => untrack(() => globalStore.reload) !== undefined
-
- const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
-
- const take = (count: number) => {
- if (queued.size === 0) return [] as string[]
- const items: string[] = []
- for (const item of queued) {
- queued.delete(item)
- items.push(item)
- if (items.length >= count) break
- }
- return items
- }
-
- const schedule = () => {
- if (timer) return
- timer = setTimeout(() => {
- timer = undefined
- void drain()
- }, 0)
- }
-
- const push = (directory: string) => {
- if (!directory) return
- queued.add(directory)
- if (paused()) return
- schedule()
- }
-
- const refresh = () => {
- root = true
- if (paused()) return
- schedule()
- }
-
- async function drain() {
- if (running) return
- running = true
- try {
- while (true) {
- if (paused()) return
-
- if (root) {
- root = false
- await bootstrap()
- await tick()
- continue
- }
-
- const dirs = take(2)
- if (dirs.length === 0) return
-
- await Promise.all(dirs.map((dir) => bootstrapInstance(dir)))
- await tick()
- }
- } finally {
- running = false
- if (paused()) return
- if (root || queued.size) schedule()
- }
- }
-
createEffect(() => {
if (!projectCacheReady()) return
if (globalStore.project.length !== 0) return
@@ -496,212 +157,43 @@ function createGlobalSync() {
createEffect(() => {
if (globalStore.reload !== "complete") return
setGlobalStore("reload", undefined)
- refresh()
+ queue.refresh()
})
- const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
- const booting = new Map<string, Promise<void>>()
- const sessionLoads = new Map<string, Promise<void>>()
- const sessionMeta = new Map<string, { limit: number }>()
-
- const sessionRecentWindow = 4 * 60 * 60 * 1000
- const sessionRecentLimit = 50
-
- function sessionUpdatedAt(session: Session) {
- return session.time.updated ?? session.time.created
- }
-
- function compareSessionRecent(a: Session, b: Session) {
- const aUpdated = sessionUpdatedAt(a)
- const bUpdated = sessionUpdatedAt(b)
- if (aUpdated !== bUpdated) return bUpdated - aUpdated
- return cmp(a.id, b.id)
- }
-
- function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
- if (limit <= 0) return [] as Session[]
- const selected: Session[] = []
- const seen = new Set<string>()
- for (const session of sessions) {
- if (!session?.id) continue
- if (seen.has(session.id)) continue
- seen.add(session.id)
-
- if (sessionUpdatedAt(session) <= cutoff) continue
-
- const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
- if (index === -1) selected.push(session)
- if (index !== -1) selected.splice(index, 0, session)
- if (selected.length > limit) selected.pop()
- }
- return selected
- }
-
- function trimSessions(input: Session[], options: { limit: number; permission: Record<string, PermissionRequest[]> }) {
- const limit = Math.max(0, options.limit)
- const cutoff = Date.now() - sessionRecentWindow
- const all = input
- .filter((s) => !!s?.id)
- .filter((s) => !s.time?.archived)
- .sort((a, b) => cmp(a.id, b.id))
-
- const roots = all.filter((s) => !s.parentID)
- const children = all.filter((s) => !!s.parentID)
-
- const base = roots.slice(0, limit)
- const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff)
- const keepRoots = [...base, ...recent]
-
- const keepRootIds = new Set(keepRoots.map((s) => s.id))
- const keepChildren = children.filter((s) => {
- if (s.parentID && keepRootIds.has(s.parentID)) return true
- const perms = options.permission[s.id] ?? []
- if (perms.length > 0) return true
- return sessionUpdatedAt(s) > cutoff
- })
-
- return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
- }
-
- function ensureChild(directory: string) {
- if (!directory) console.error("No directory provided")
- if (!children[directory]) {
- const vcs = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "vcs", ["vcs.v1"]),
- createStore({ value: undefined as VcsInfo | undefined }),
- ),
- )
- if (!vcs) throw new Error("Failed to create persisted cache")
- const vcsStore = vcs[0]
- const vcsReady = vcs[3]
- vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
-
- const meta = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "project", ["project.v1"]),
- createStore({ value: undefined as ProjectMeta | undefined }),
- ),
- )
- if (!meta) throw new Error("Failed to create persisted project metadata")
- metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
-
- const icon = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "icon", ["icon.v1"]),
- createStore({ value: undefined as string | undefined }),
- ),
- )
- if (!icon) throw new Error("Failed to create persisted project icon")
- iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
-
- const init = () =>
- createRoot((dispose) => {
- const child = createStore<State>({
- project: "",
- projectMeta: meta[0].value,
- icon: icon[0].value,
- provider: { all: [], connected: [], default: {} },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- status: "loading" as const,
- agent: [],
- command: [],
- session: [],
- sessionTotal: 0,
- session_status: {},
- session_diff: {},
- todo: {},
- permission: {},
- question: {},
- mcp: {},
- lsp: [],
- vcs: vcsStore.value,
- limit: 5,
- message: {},
- part: {},
- })
-
- children[directory] = child
- disposers.set(directory, dispose)
-
- createEffect(() => {
- if (!vcsReady()) return
- const cached = vcsStore.value
- if (!cached?.branch) return
- child[1]("vcs", (value) => value ?? cached)
- })
-
- createEffect(() => {
- child[1]("projectMeta", meta[0].value)
- })
-
- createEffect(() => {
- child[1]("icon", icon[0].value)
- })
- })
-
- runWithOwner(owner, init)
- updateStats()
- }
- mark(directory)
- const childStore = children[directory]
- if (!childStore) throw new Error("Failed to create store")
- return childStore
- }
-
- function child(directory: string, options: ChildOptions = {}) {
- const childStore = ensureChild(directory)
- pinForOwner(directory)
- const shouldBootstrap = options.bootstrap ?? true
- if (shouldBootstrap && childStore[0].status === "loading") {
- void bootstrapInstance(directory)
- }
- return childStore
- }
-
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
- pin(directory)
- const [store, setStore] = child(directory, { bootstrap: false })
+ children.pin(directory)
+ const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
- unpin(directory)
+ children.unpin(directory)
return
}
- const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit)
+ const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = loadRootSessionsWithFallback({
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
- updateStats()
+ updateStats(Object.keys(children.children).length)
},
})
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
- .sort((a, b) => cmp(a.id, b.id))
-
- // Read the current limit at resolve-time so callers that bump the limit while
- // a request is in-flight still get the expanded result.
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
-
- const children = store.session.filter((s) => !!s.parentID)
- const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
-
- // Store root session total for "load more" pagination.
- // For limited root queries, preserve has-more behavior by treating
- // full-limit responses as "potentially more".
+ const childSessions = store.session.filter((s) => !!s.parentID)
+ const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
setStore(
"sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
@@ -718,7 +210,7 @@ function createGlobalSync() {
sessionLoads.set(directory, promise)
promise.finally(() => {
sessionLoads.delete(directory)
- unpin(directory)
+ children.unpin(directory)
})
return promise
}
@@ -728,571 +220,99 @@ function createGlobalSync() {
const pending = booting.get(directory)
if (pending) return pending
- pin(directory)
+ children.pin(directory)
const promise = (async () => {
- const [store, setStore] = ensureChild(directory)
- const cache = vcsCache.get(directory)
+ const child = children.ensureChild(directory)
+ const cache = children.vcsCache.get(directory)
if (!cache) return
- const meta = metaCache.get(directory)
- if (!meta) return
const sdk = sdkFor(directory)
-
- setStore("status", "loading")
-
- // projectMeta is synced from persisted storage in ensureChild.
- // vcs is seeded from persisted storage in ensureChild.
-
- const blockingRequests = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () =>
- sdk.provider.list().then((x) => {
- setStore("provider", normalizeProviderList(x.data!))
- }),
- agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
- config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
- }
-
- try {
- await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
- } catch (err) {
- console.error("Failed to bootstrap instance", err)
- const project = getFilename(directory)
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: `Failed to reload ${project}`, description: message })
- setStore("status", "partial")
- return
- }
-
- if (store.status !== "complete") setStore("status", "partial")
-
- Promise.all([
- sdk.path.get().then((x) => setStore("path", x.data!)),
- sdk.command.list().then((x) => setStore("command", x.data ?? [])),
- sdk.session.status().then((x) => setStore("session_status", x.data!)),
- loadSessions(directory),
- sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
- sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
- sdk.vcs.get().then((x) => {
- const next = x.data ?? store.vcs
- setStore("vcs", next)
- if (next?.branch) cache.setStore("value", next)
- }),
- sdk.permission.list().then((x) => {
- const grouped: Record<string, PermissionRequest[]> = {}
- for (const perm of x.data ?? []) {
- if (!perm?.id || !perm.sessionID) continue
- const existing = grouped[perm.sessionID]
- if (existing) {
- existing.push(perm)
- continue
- }
- grouped[perm.sessionID] = [perm]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.permission)) {
- if (grouped[sessionID]) continue
- setStore("permission", sessionID, [])
- }
- for (const [sessionID, permissions] of Object.entries(grouped)) {
- setStore(
- "permission",
- sessionID,
- reconcile(
- permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- sdk.question.list().then((x) => {
- const grouped: Record<string, QuestionRequest[]> = {}
- for (const question of x.data ?? []) {
- if (!question?.id || !question.sessionID) continue
- const existing = grouped[question.sessionID]
- if (existing) {
- existing.push(question)
- continue
- }
- grouped[question.sessionID] = [question]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.question)) {
- if (grouped[sessionID]) continue
- setStore("question", sessionID, [])
- }
- for (const [sessionID, questions] of Object.entries(grouped)) {
- setStore(
- "question",
- sessionID,
- reconcile(
- questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- ]).then(() => {
- setStore("status", "complete")
+ await bootstrapDirectory({
+ directory,
+ sdk,
+ store: child[0],
+ setStore: child[1],
+ vcsCache: cache,
+ loadSessions,
})
})()
booting.set(directory, promise)
promise.finally(() => {
booting.delete(directory)
- unpin(directory)
+ children.unpin(directory)
})
return promise
}
- function purgeMessageParts(setStore: SetStoreFunction<State>, messageID: string | undefined) {
- if (!messageID) return
- setStore(
- produce((draft) => {
- delete draft.part[messageID]
- }),
- )
- }
-
- function purgeSessionData(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string | undefined) {
- if (!sessionID) return
-
- const messages = store.message[sessionID]
- const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id)
-
- setStore(
- produce((draft) => {
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
-
- for (const messageID of messageIDs) {
- delete draft.part[messageID]
- }
- }),
- )
- }
-
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (directory === "global") {
- switch (event?.type) {
- case "global.disposed": {
- refresh()
- return
- }
- case "project.updated": {
- const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
- if (result.found) {
- setGlobalStore("project", result.index, reconcile(event.properties))
+ applyGlobalEvent({
+ event,
+ project: globalStore.project,
+ refresh: queue.refresh,
+ setGlobalProject(next) {
+ if (typeof next === "function") {
+ setGlobalStore("project", produce(next))
return
}
- setGlobalStore(
- "project",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- }
+ setGlobalStore("project", next)
+ },
+ })
return
}
- const existing = children[directory]
+ const existing = children.children[directory]
if (!existing) return
- mark(directory)
-
+ children.mark(directory)
const [store, setStore] = existing
-
- const cleanupSessionCaches = (sessionID: string) => {
- if (!sessionID) return
-
- const hasAny =
- store.message[sessionID] !== undefined ||
- store.session_diff[sessionID] !== undefined ||
- store.todo[sessionID] !== undefined ||
- store.permission[sessionID] !== undefined ||
- store.question[sessionID] !== undefined ||
- store.session_status[sessionID] !== undefined
-
- if (!hasAny) return
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- for (const message of messages) {
- const id = message?.id
- if (!id) continue
- delete draft.part[id]
- }
- }
-
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
- }),
- )
- }
-
- switch (event.type) {
- case "server.instance.disposed": {
- push(directory)
- return
- }
- case "session.created": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- if (!info.parentID) {
- setStore("sessionTotal", (value) => value + 1)
- }
- break
- }
- case "session.updated": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (info.time.archived) {
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(info.id)
- if (info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- break
- }
- case "session.deleted": {
- const sessionID = event.properties.info.id
- const result = Binary.search(store.session, sessionID, (s) => s.id)
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(sessionID)
- if (event.properties.info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- case "session.diff":
- setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
- break
- case "todo.updated":
- setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
- break
- case "session.status": {
- setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
- break
- }
- case "message.updated": {
- const messages = store.message[event.properties.info.sessionID]
- if (!messages) {
- setStore("message", event.properties.info.sessionID, [event.properties.info])
- break
- }
- const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
- if (result.found) {
- setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "message",
- event.properties.info.sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "message.removed": {
- const sessionID = event.properties.sessionID
- const messageID = event.properties.messageID
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) {
- messages.splice(result.index, 1)
- }
- }
-
- delete draft.part[messageID]
- }),
- )
- break
- }
- case "message.part.updated": {
- const part = event.properties.part
- const parts = store.part[part.messageID]
- if (!parts) {
- setStore("part", part.messageID, [part])
- break
- }
- const result = Binary.search(parts, part.id, (p) => p.id)
- if (result.found) {
- setStore("part", part.messageID, result.index, reconcile(part))
- break
- }
- setStore(
- "part",
- part.messageID,
- produce((draft) => {
- draft.splice(result.index, 0, part)
- }),
- )
- break
- }
- case "message.part.removed": {
- const messageID = event.properties.messageID
- const parts = store.part[messageID]
- if (!parts) break
- const result = Binary.search(parts, event.properties.partID, (p) => p.id)
- if (result.found) {
- setStore(
- produce((draft) => {
- const list = draft.part[messageID]
- if (!list) return
- const next = Binary.search(list, event.properties.partID, (p) => p.id)
- if (!next.found) return
- list.splice(next.index, 1)
- if (list.length === 0) delete draft.part[messageID]
- }),
- )
- }
- break
- }
- case "vcs.branch.updated": {
- const next = { branch: event.properties.branch }
- setStore("vcs", next)
- const cache = vcsCache.get(directory)
- if (cache) cache.setStore("value", next)
- break
- }
- case "permission.asked": {
- const sessionID = event.properties.sessionID
- const permissions = store.permission[sessionID]
- if (!permissions) {
- setStore("permission", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(permissions, event.properties.id, (p) => p.id)
- if (result.found) {
- setStore("permission", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "permission",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "permission.replied": {
- const permissions = store.permission[event.properties.sessionID]
- if (!permissions) break
- const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
- if (!result.found) break
- setStore(
- "permission",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "question.asked": {
- const sessionID = event.properties.sessionID
- const questions = store.question[sessionID]
- if (!questions) {
- setStore("question", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(questions, event.properties.id, (q) => q.id)
- if (result.found) {
- setStore("question", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "question",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "question.replied":
- case "question.rejected": {
- const questions = store.question[event.properties.sessionID]
- if (!questions) break
- const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
- if (!result.found) break
- setStore(
- "question",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "lsp.updated": {
+ applyDirectoryEvent({
+ event,
+ directory,
+ store,
+ setStore,
+ push: queue.push,
+ vcsCache: children.vcsCache.get(directory),
+ loadLsp: () => {
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
- break
- }
- }
+ },
+ })
})
+
onCleanup(unsub)
onCleanup(() => {
- if (!timer) return
- clearTimeout(timer)
+ queue.dispose()
})
onCleanup(() => {
- for (const directory of Object.keys(children)) {
- disposeDirectory(directory)
+ for (const directory of Object.keys(children.children)) {
+ children.disposeDirectory(directory)
}
})
async function bootstrap() {
- const health = await globalSDK.client.global
- .health()
- .then((x) => x.data)
- .catch(() => undefined)
- if (!health?.healthy) {
- showToast({
- variant: "error",
- title: language.t("dialog.server.add.error"),
- description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
- })
- setGlobalStore("ready", true)
- return
- }
-
- const tasks = [
- retry(() =>
- globalSDK.client.path.get().then((x) => {
- setGlobalStore("path", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.global.config.get().then((x) => {
- setGlobalStore("config", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.project.list().then(async (x) => {
- const projects = (x.data ?? [])
- .filter((p) => !!p?.id)
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
- .slice()
- .sort((a, b) => cmp(a.id, b.id))
- setGlobalStore("project", projects)
- }),
- ),
- retry(() =>
- globalSDK.client.provider.list().then((x) => {
- setGlobalStore("provider", normalizeProviderList(x.data!))
- }),
- ),
- retry(() =>
- globalSDK.client.provider.auth().then((x) => {
- setGlobalStore("provider_auth", x.data ?? {})
- }),
- ),
- ]
-
- const results = await Promise.allSettled(tasks)
- const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
-
- if (errors.length) {
- const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
- const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: message + more,
- })
- }
-
- setGlobalStore("ready", true)
+ await bootstrapGlobal({
+ globalSDK: globalSDK.client,
+ connectErrorTitle: language.t("dialog.server.add.error"),
+ connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
+ requestFailedTitle: language.t("common.requestFailed"),
+ setGlobalStore,
+ })
}
onMount(() => {
- bootstrap()
+ void bootstrap()
})
function projectMeta(directory: string, patch: ProjectMeta) {
- const [store, setStore] = ensureChild(directory)
- const cached = metaCache.get(directory)
- if (!cached) return
- const previous = store.projectMeta ?? {}
- const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
- const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
- const next = {
- ...previous,
- ...patch,
- icon,
- commands,
- }
- cached.setStore("value", next)
- setStore("projectMeta", next)
+ children.projectMeta(directory, patch)
}
function projectIcon(directory: string, value: string | undefined) {
- const [store, setStore] = ensureChild(directory)
- const cached = iconCache.get(directory)
- if (!cached) return
- if (store.icon === value) return
- cached.setStore("value", value)
- setStore("icon", value)
+ children.projectIcon(directory, value)
}
return {
@@ -1304,7 +324,7 @@ function createGlobalSync() {
get error() {
return globalStore.error
},
- child,
+ child: children.child,
bootstrap,
updateConfig: (config: Config) => {
setGlobalStore("reload", "pending")
@@ -1340,3 +360,6 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
+
+export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
+export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
new file mode 100644
index 000000000..2137a19a8
--- /dev/null
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -0,0 +1,195 @@
+import {
+ type Config,
+ type Path,
+ type PermissionRequest,
+ type Project,
+ type ProviderAuthResponse,
+ type ProviderListResponse,
+ type QuestionRequest,
+ createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { batch } from "solid-js"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import { retry } from "@opencode-ai/util/retry"
+import { getFilename } from "@opencode-ai/util/path"
+import { showToast } from "@opencode-ai/ui/toast"
+import { cmp, normalizeProviderList } from "./utils"
+import type { State, VcsCache } from "./types"
+
+type GlobalStore = {
+ ready: boolean
+ path: Path
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ config: Config
+ reload: undefined | "pending" | "complete"
+}
+
+export async function bootstrapGlobal(input: {
+ globalSDK: ReturnType<typeof createOpencodeClient>
+ connectErrorTitle: string
+ connectErrorDescription: string
+ requestFailedTitle: string
+ setGlobalStore: SetStoreFunction<GlobalStore>
+}) {
+ const health = await input.globalSDK.global
+ .health()
+ .then((x) => x.data)
+ .catch(() => undefined)
+ if (!health?.healthy) {
+ showToast({
+ variant: "error",
+ title: input.connectErrorTitle,
+ description: input.connectErrorDescription,
+ })
+ input.setGlobalStore("ready", true)
+ return
+ }
+
+ const tasks = [
+ retry(() =>
+ input.globalSDK.path.get().then((x) => {
+ input.setGlobalStore("path", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.global.config.get().then((x) => {
+ input.setGlobalStore("config", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.project.list().then((x) => {
+ const projects = (x.data ?? [])
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => cmp(a.id, b.id))
+ input.setGlobalStore("project", projects)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.list().then((x) => {
+ input.setGlobalStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.auth().then((x) => {
+ input.setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ),
+ ]
+
+ const results = await Promise.allSettled(tasks)
+ const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+ if (errors.length) {
+ const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+ const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+ showToast({
+ variant: "error",
+ title: input.requestFailedTitle,
+ description: message + more,
+ })
+ }
+ input.setGlobalStore("ready", true)
+}
+
+function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
+ return input.reduce<Record<string, T[]>>((acc, item) => {
+ if (!item?.id || !item.sessionID) return acc
+ const list = acc[item.sessionID]
+ if (list) list.push(item)
+ if (!list) acc[item.sessionID] = [item]
+ return acc
+ }, {})
+}
+
+export async function bootstrapDirectory(input: {
+ directory: string
+ sdk: ReturnType<typeof createOpencodeClient>
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ vcsCache: VcsCache
+ loadSessions: (directory: string) => Promise<void> | void
+}) {
+ input.setStore("status", "loading")
+
+ const blockingRequests = {
+ project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+ provider: () =>
+ input.sdk.provider.list().then((x) => {
+ input.setStore("provider", normalizeProviderList(x.data!))
+ }),
+ agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
+ config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+ }
+
+ try {
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+ } catch (err) {
+ console.error("Failed to bootstrap instance", err)
+ const project = getFilename(input.directory)
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: `Failed to reload ${project}`, description: message })
+ input.setStore("status", "partial")
+ return
+ }
+
+ if (input.store.status !== "complete") input.setStore("status", "partial")
+
+ Promise.all([
+ input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
+ input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
+ input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
+ input.loadSessions(input.directory),
+ input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
+ input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
+ input.sdk.vcs.get().then((x) => {
+ const next = x.data ?? input.store.vcs
+ input.setStore("vcs", next)
+ if (next?.branch) input.vcsCache.setStore("value", next)
+ }),
+ input.sdk.permission.list().then((x) => {
+ const grouped = groupBySession(
+ (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+ )
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.permission)) {
+ if (grouped[sessionID]) continue
+ input.setStore("permission", sessionID, [])
+ }
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
+ input.setStore(
+ "permission",
+ sessionID,
+ reconcile(
+ permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ input.sdk.question.list().then((x) => {
+ const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.question)) {
+ if (grouped[sessionID]) continue
+ input.setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ input.setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ]).then(() => {
+ input.setStore("status", "complete")
+ })
+}
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
new file mode 100644
index 000000000..2feb7fe08
--- /dev/null
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -0,0 +1,263 @@
+import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import {
+ DIR_IDLE_TTL_MS,
+ MAX_DIR_STORES,
+ type ChildOptions,
+ type DirState,
+ type IconCache,
+ type MetaCache,
+ type ProjectMeta,
+ type State,
+ type VcsCache,
+} from "./types"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+
+export function createChildStoreManager(input: {
+ owner: Owner
+ markStats: (activeDirectoryStores: number) => void
+ incrementEvictions: () => void
+ isBooting: (directory: string) => boolean
+ isLoadingSessions: (directory: string) => boolean
+ onBootstrap: (directory: string) => void
+ onDispose: (directory: string) => void
+}) {
+ const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
+ const vcsCache = new Map<string, VcsCache>()
+ const metaCache = new Map<string, MetaCache>()
+ const iconCache = new Map<string, IconCache>()
+ const lifecycle = new Map<string, DirState>()
+ const pins = new Map<string, number>()
+ const ownerPins = new WeakMap<object, Set<string>>()
+ const disposers = new Map<string, () => void>()
+
+ const mark = (directory: string) => {
+ if (!directory) return
+ lifecycle.set(directory, { lastAccessAt: Date.now() })
+ runEviction()
+ }
+
+ const pin = (directory: string) => {
+ if (!directory) return
+ pins.set(directory, (pins.get(directory) ?? 0) + 1)
+ mark(directory)
+ }
+
+ const unpin = (directory: string) => {
+ if (!directory) return
+ const next = (pins.get(directory) ?? 0) - 1
+ if (next > 0) {
+ pins.set(directory, next)
+ return
+ }
+ pins.delete(directory)
+ runEviction()
+ }
+
+ const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+ const pinForOwner = (directory: string) => {
+ const current = getOwner()
+ if (!current) return
+ if (current === input.owner) return
+ const key = current as object
+ const set = ownerPins.get(key)
+ if (set?.has(directory)) return
+ if (set) set.add(directory)
+ if (!set) ownerPins.set(key, new Set([directory]))
+ pin(directory)
+ onCleanup(() => {
+ const set = ownerPins.get(key)
+ if (set) {
+ set.delete(directory)
+ if (set.size === 0) ownerPins.delete(key)
+ }
+ unpin(directory)
+ })
+ }
+
+ function disposeDirectory(directory: string) {
+ if (
+ !canDisposeDirectory({
+ directory,
+ hasStore: !!children[directory],
+ pinned: pinned(directory),
+ booting: input.isBooting(directory),
+ loadingSessions: input.isLoadingSessions(directory),
+ })
+ ) {
+ return false
+ }
+
+ vcsCache.delete(directory)
+ metaCache.delete(directory)
+ iconCache.delete(directory)
+ lifecycle.delete(directory)
+ const dispose = disposers.get(directory)
+ if (dispose) {
+ dispose()
+ disposers.delete(directory)
+ }
+ delete children[directory]
+ input.onDispose(directory)
+ input.markStats(Object.keys(children).length)
+ return true
+ }
+
+ function runEviction() {
+ const stores = Object.keys(children)
+ if (stores.length === 0) return
+ const list = pickDirectoriesToEvict({
+ stores,
+ state: lifecycle,
+ pins: new Set(stores.filter(pinned)),
+ max: MAX_DIR_STORES,
+ ttl: DIR_IDLE_TTL_MS,
+ now: Date.now(),
+ })
+ if (list.length === 0) return
+ for (const directory of list) {
+ if (!disposeDirectory(directory)) continue
+ input.incrementEvictions()
+ }
+ }
+
+ function ensureChild(directory: string) {
+ if (!directory) console.error("No directory provided")
+ if (!children[directory]) {
+ const vcs = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "vcs", ["vcs.v1"]),
+ createStore({ value: undefined as VcsInfo | undefined }),
+ ),
+ )
+ if (!vcs) throw new Error("Failed to create persisted cache")
+ const vcsStore = vcs[0]
+ const vcsReady = vcs[3]
+ vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+
+ const meta = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "project", ["project.v1"]),
+ createStore({ value: undefined as ProjectMeta | undefined }),
+ ),
+ )
+ if (!meta) throw new Error("Failed to create persisted project metadata")
+ metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+
+ const icon = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "icon", ["icon.v1"]),
+ createStore({ value: undefined as string | undefined }),
+ ),
+ )
+ if (!icon) throw new Error("Failed to create persisted project icon")
+ iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+
+ const init = () =>
+ createRoot((dispose) => {
+ const child = createStore<State>({
+ project: "",
+ projectMeta: meta[0].value,
+ icon: icon[0].value,
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ status: "loading" as const,
+ agent: [],
+ command: [],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: vcsStore.value,
+ limit: 5,
+ message: {},
+ part: {},
+ })
+ children[directory] = child
+ disposers.set(directory, dispose)
+
+ createEffect(() => {
+ if (!vcsReady()) return
+ const cached = vcsStore.value
+ if (!cached?.branch) return
+ child[1]("vcs", (value) => value ?? cached)
+ })
+ createEffect(() => {
+ child[1]("projectMeta", meta[0].value)
+ })
+ createEffect(() => {
+ child[1]("icon", icon[0].value)
+ })
+ })
+
+ runWithOwner(input.owner, init)
+ input.markStats(Object.keys(children).length)
+ }
+ mark(directory)
+ const childStore = children[directory]
+ if (!childStore) throw new Error("Failed to create store")
+ return childStore
+ }
+
+ function child(directory: string, options: ChildOptions = {}) {
+ const childStore = ensureChild(directory)
+ pinForOwner(directory)
+ const shouldBootstrap = options.bootstrap ?? true
+ if (shouldBootstrap && childStore[0].status === "loading") {
+ input.onBootstrap(directory)
+ }
+ return childStore
+ }
+
+ function projectMeta(directory: string, patch: ProjectMeta) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = metaCache.get(directory)
+ if (!cached) return
+ const previous = store.projectMeta ?? {}
+ const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
+ const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
+ const next = {
+ ...previous,
+ ...patch,
+ icon,
+ commands,
+ }
+ cached.setStore("value", next)
+ setStore("projectMeta", next)
+ }
+
+ function projectIcon(directory: string, value: string | undefined) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = iconCache.get(directory)
+ if (!cached) return
+ if (store.icon === value) return
+ cached.setStore("value", value)
+ setStore("icon", value)
+ }
+
+ return {
+ children,
+ ensureChild,
+ child,
+ projectMeta,
+ projectIcon,
+ mark,
+ pin,
+ unpin,
+ pinned,
+ disposeDirectory,
+ runEviction,
+ vcsCache,
+ metaCache,
+ iconCache,
+ }
+}
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
new file mode 100644
index 000000000..f79b9fc95
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
+import { createStore } from "solid-js/store"
+import type { State } from "./types"
+import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+
+const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: 1,
+ updated: 1,
+ archived: input.archived,
+ },
+ }) as Session
+
+const userMessage = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+ }) as Message
+
+const textPart = (id: string, sessionID: string, messageID: string) =>
+ ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+ }) as Part
+
+const baseState = (input: Partial<State> = {}) =>
+ ({
+ status: "complete",
+ agent: [],
+ command: [],
+ project: "",
+ projectMeta: undefined,
+ icon: undefined,
+ provider: {} as State["provider"],
+ config: {} as State["config"],
+ path: { directory: "/tmp" } as State["path"],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: undefined,
+ limit: 10,
+ message: {},
+ part: {},
+ ...input,
+ }) as State
+
+describe("applyGlobalEvent", () => {
+ test("upserts project.updated in sorted position", () => {
+ const project = [{ id: "a" }, { id: "c" }] as Project[]
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "project.updated", properties: { id: "b" } },
+ project,
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject(next) {
+ if (typeof next === "function") next(project)
+ },
+ })
+
+ expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
+ expect(refreshCount).toBe(0)
+ })
+
+ test("handles global.disposed by triggering refresh", () => {
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "global.disposed" },
+ project: [],
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject() {},
+ })
+
+ expect(refreshCount).toBe(1)
+ })
+})
+
+describe("applyDirectoryEvent", () => {
+ test("inserts root sessions in sorted order and updates sessionTotal", () => {
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "b" })],
+ sessionTotal: 1,
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
+ expect(store.sessionTotal).toBe(2)
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.sessionTotal).toBe(2)
+ })
+
+ test("cleans session caches when archived", () => {
+ const message = userMessage("msg_1", "ses_1")
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
+ sessionTotal: 2,
+ message: { ses_1: [message] },
+ part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
+ session_diff: { ses_1: [] },
+ todo: { ses_1: [] },
+ permission: { ses_1: [] },
+ question: { ses_1: [] },
+ session_status: { ses_1: { type: "busy" } },
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
+ expect(store.sessionTotal).toBe(1)
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part[message.id]).toBeUndefined()
+ expect(store.session_diff.ses_1).toBeUndefined()
+ expect(store.todo.ses_1).toBeUndefined()
+ expect(store.permission.ses_1).toBeUndefined()
+ expect(store.question.ses_1).toBeUndefined()
+ expect(store.session_status.ses_1).toBeUndefined()
+ })
+
+ test("routes disposal and lsp events to side-effect handlers", () => {
+ const [store, setStore] = createStore(baseState())
+ const pushes: string[] = []
+ let lspLoads = 0
+
+ applyDirectoryEvent({
+ event: { type: "server.instance.disposed" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ applyDirectoryEvent({
+ event: { type: "lsp.updated" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ expect(pushes).toEqual(["/tmp"])
+ expect(lspLoads).toBe(1)
+ })
+})
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
new file mode 100644
index 000000000..c658d82c8
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -0,0 +1,319 @@
+import { Binary } from "@opencode-ai/util/binary"
+import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ Project,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+import type { State, VcsCache } from "./types"
+import { trimSessions } from "./session-trim"
+
+export function applyGlobalEvent(input: {
+ event: { type: string; properties?: unknown }
+ project: Project[]
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ refresh: () => void
+}) {
+ if (input.event.type === "global.disposed") {
+ input.refresh()
+ return
+ }
+
+ if (input.event.type !== "project.updated") return
+ const properties = input.event.properties as Project
+ const result = Binary.search(input.project, properties.id, (s) => s.id)
+ if (result.found) {
+ input.setGlobalProject((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ })
+ return
+ }
+ input.setGlobalProject((draft) => {
+ draft.splice(result.index, 0, properties)
+ })
+}
+
+function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
+ if (!sessionID) return
+ const hasAny =
+ store.message[sessionID] !== undefined ||
+ store.session_diff[sessionID] !== undefined ||
+ store.todo[sessionID] !== undefined ||
+ store.permission[sessionID] !== undefined ||
+ store.question[sessionID] !== undefined ||
+ store.session_status[sessionID] !== undefined
+ if (!hasAny) return
+ setStore(
+ produce((draft) => {
+ const messages = draft.message[sessionID]
+ if (messages) {
+ for (const message of messages) {
+ const id = message?.id
+ if (!id) continue
+ delete draft.part[id]
+ }
+ }
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+ }),
+ )
+}
+
+export function applyDirectoryEvent(input: {
+ event: { type: string; properties?: unknown }
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ push: (directory: string) => void
+ directory: string
+ loadLsp: () => void
+ vcsCache?: VcsCache
+}) {
+ const event = input.event
+ switch (event.type) {
+ case "server.instance.disposed": {
+ input.push(input.directory)
+ return
+ }
+ case "session.created": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
+ break
+ }
+ case "session.updated": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (info.time.archived) {
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ break
+ }
+ case "session.deleted": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ case "session.diff": {
+ const props = event.properties as { sessionID: string; diff: FileDiff[] }
+ input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+ break
+ }
+ case "todo.updated": {
+ const props = event.properties as { sessionID: string; todos: Todo[] }
+ input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+ break
+ }
+ case "session.status": {
+ const props = event.properties as { sessionID: string; status: SessionStatus }
+ input.setStore("session_status", props.sessionID, reconcile(props.status))
+ break
+ }
+ case "message.updated": {
+ const info = (event.properties as { info: Message }).info
+ const messages = input.store.message[info.sessionID]
+ if (!messages) {
+ input.setStore("message", info.sessionID, [info])
+ break
+ }
+ const result = Binary.search(messages, info.id, (m) => m.id)
+ if (result.found) {
+ input.setStore("message", info.sessionID, result.index, reconcile(info))
+ break
+ }
+ input.setStore(
+ "message",
+ info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, info)
+ }),
+ )
+ break
+ }
+ case "message.removed": {
+ const props = event.properties as { sessionID: string; messageID: string }
+ input.setStore(
+ produce((draft) => {
+ const messages = draft.message[props.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, props.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[props.messageID]
+ }),
+ )
+ break
+ }
+ case "message.part.updated": {
+ const part = (event.properties as { part: Part }).part
+ const parts = input.store.part[part.messageID]
+ if (!parts) {
+ input.setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ input.setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ case "message.part.removed": {
+ const props = event.properties as { messageID: string; partID: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (result.found) {
+ input.setStore(
+ produce((draft) => {
+ const list = draft.part[props.messageID]
+ if (!list) return
+ const next = Binary.search(list, props.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[props.messageID]
+ }),
+ )
+ }
+ break
+ }
+ case "vcs.branch.updated": {
+ const props = event.properties as { branch: string }
+ const next = { branch: props.branch }
+ input.setStore("vcs", next)
+ if (input.vcsCache) input.vcsCache.setStore("value", next)
+ break
+ }
+ case "permission.asked": {
+ const permission = event.properties as PermissionRequest
+ const permissions = input.store.permission[permission.sessionID]
+ if (!permissions) {
+ input.setStore("permission", permission.sessionID, [permission])
+ break
+ }
+ const result = Binary.search(permissions, permission.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
+ break
+ }
+ input.setStore(
+ "permission",
+ permission.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, permission)
+ }),
+ )
+ break
+ }
+ case "permission.replied": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const permissions = input.store.permission[props.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, props.requestID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "permission",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "question.asked": {
+ const question = event.properties as QuestionRequest
+ const questions = input.store.question[question.sessionID]
+ if (!questions) {
+ input.setStore("question", question.sessionID, [question])
+ break
+ }
+ const result = Binary.search(questions, question.id, (q) => q.id)
+ if (result.found) {
+ input.setStore("question", question.sessionID, result.index, reconcile(question))
+ break
+ }
+ input.setStore(
+ "question",
+ question.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, question)
+ }),
+ )
+ break
+ }
+ case "question.replied":
+ case "question.rejected": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const questions = input.store.question[props.sessionID]
+ if (!questions) break
+ const result = Binary.search(questions, props.requestID, (q) => q.id)
+ if (!result.found) break
+ input.setStore(
+ "question",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "lsp.updated": {
+ input.loadLsp()
+ break
+ }
+ }
+}
diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts
new file mode 100644
index 000000000..676a6ee17
--- /dev/null
+++ b/packages/app/src/context/global-sync/eviction.ts
@@ -0,0 +1,28 @@
+import type { DisposeCheck, EvictPlan } from "./types"
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+ const overflow = Math.max(0, input.stores.length - input.max)
+ let pendingOverflow = overflow
+ const sorted = input.stores
+ .filter((dir) => !input.pins.has(dir))
+ .slice()
+ .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+ const output: string[] = []
+ for (const dir of sorted) {
+ const last = input.state.get(dir)?.lastAccessAt ?? 0
+ const idle = input.now - last >= input.ttl
+ if (!idle && pendingOverflow <= 0) continue
+ output.push(dir)
+ if (pendingOverflow > 0) pendingOverflow -= 1
+ }
+ return output
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+ if (!input.directory) return false
+ if (!input.hasStore) return false
+ if (input.pinned) return false
+ if (input.booting) return false
+ if (input.loadingSessions) return false
+ return true
+}
diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts
new file mode 100644
index 000000000..c3468583b
--- /dev/null
+++ b/packages/app/src/context/global-sync/queue.ts
@@ -0,0 +1,83 @@
+type QueueInput = {
+ paused: () => boolean
+ bootstrap: () => Promise<void>
+ bootstrapInstance: (directory: string) => Promise<void> | void
+}
+
+export function createRefreshQueue(input: QueueInput) {
+ const queued = new Set<string>()
+ let root = false
+ let running = false
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
+
+ const take = (count: number) => {
+ if (queued.size === 0) return [] as string[]
+ const items: string[] = []
+ for (const item of queued) {
+ queued.delete(item)
+ items.push(item)
+ if (items.length >= count) break
+ }
+ return items
+ }
+
+ const schedule = () => {
+ if (timer) return
+ timer = setTimeout(() => {
+ timer = undefined
+ void drain()
+ }, 0)
+ }
+
+ const push = (directory: string) => {
+ if (!directory) return
+ queued.add(directory)
+ if (input.paused()) return
+ schedule()
+ }
+
+ const refresh = () => {
+ root = true
+ if (input.paused()) return
+ schedule()
+ }
+
+ async function drain() {
+ if (running) return
+ running = true
+ try {
+ while (true) {
+ if (input.paused()) return
+ if (root) {
+ root = false
+ await input.bootstrap()
+ await tick()
+ continue
+ }
+ const dirs = take(2)
+ if (dirs.length === 0) return
+ await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
+ await tick()
+ }
+ } finally {
+ running = false
+ if (input.paused()) return
+ if (root || queued.size) schedule()
+ }
+ }
+
+ return {
+ push,
+ refresh,
+ clear(directory: string) {
+ queued.delete(directory)
+ },
+ dispose() {
+ if (!timer) return
+ clearTimeout(timer)
+ timer = undefined
+ },
+ }
+}
diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts
new file mode 100644
index 000000000..443aa8450
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-load.ts
@@ -0,0 +1,26 @@
+import type { RootLoadArgs } from "./types"
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+ try {
+ const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: true,
+ } as const
+ } catch {
+ input.onFallback()
+ const result = await input.list({ directory: input.directory, roots: true })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: false,
+ } as const
+ }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+ if (!input.limited) return input.count
+ if (input.count < input.limit) return input.count
+ return input.count + 1
+}
diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts
new file mode 100644
index 000000000..be12c074b
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { trimSessions } from "./session-trim"
+
+const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: input.created,
+ updated: input.updated,
+ archived: input.archived,
+ },
+ }) as Session
+
+describe("trimSessions", () => {
+ test("keeps base roots and recent roots beyond the limit", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "a", created: now - 100_000 }),
+ session({ id: "b", created: now - 90_000 }),
+ session({ id: "c", created: now - 80_000 }),
+ session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
+ session({ id: "e", created: now - 60_000, archived: now - 10 }),
+ ]
+
+ const result = trimSessions(list, { limit: 2, permission: {}, now })
+ expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
+ })
+
+ test("keeps children when root is kept, permission exists, or child is recent", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "root-1", created: now - 1000 }),
+ session({ id: "root-2", created: now - 2000 }),
+ session({ id: "z-root", created: now - 30_000_000 }),
+ session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
+ session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
+ ]
+
+ const result = trimSessions(list, {
+ limit: 2,
+ permission: {
+ "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+ },
+ now,
+ })
+
+ expect(result.map((x) => x.id)).toEqual([
+ "child-kept-by-permission",
+ "child-kept-by-recency",
+ "child-kept-by-root",
+ "root-1",
+ "root-2",
+ ])
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts
new file mode 100644
index 000000000..800ba74a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.ts
@@ -0,0 +1,56 @@
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { cmp } from "./utils"
+import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
+
+export function sessionUpdatedAt(session: Session) {
+ return session.time.updated ?? session.time.created
+}
+
+export function compareSessionRecent(a: Session, b: Session) {
+ const aUpdated = sessionUpdatedAt(a)
+ const bUpdated = sessionUpdatedAt(b)
+ if (aUpdated !== bUpdated) return bUpdated - aUpdated
+ return cmp(a.id, b.id)
+}
+
+export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
+ if (limit <= 0) return [] as Session[]
+ const selected: Session[] = []
+ const seen = new Set<string>()
+ for (const session of sessions) {
+ if (!session?.id) continue
+ if (seen.has(session.id)) continue
+ seen.add(session.id)
+ if (sessionUpdatedAt(session) <= cutoff) continue
+ const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
+ if (index === -1) selected.push(session)
+ if (index !== -1) selected.splice(index, 0, session)
+ if (selected.length > limit) selected.pop()
+ }
+ return selected
+}
+
+export function trimSessions(
+ input: Session[],
+ options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
+) {
+ const limit = Math.max(0, options.limit)
+ const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+ const all = input
+ .filter((s) => !!s?.id)
+ .filter((s) => !s.time?.archived)
+ .sort((a, b) => cmp(a.id, b.id))
+ const roots = all.filter((s) => !s.parentID)
+ const children = all.filter((s) => !!s.parentID)
+ const base = roots.slice(0, limit)
+ const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+ const keepRoots = [...base, ...recent]
+ const keepRootIds = new Set(keepRoots.map((s) => s.id))
+ const keepChildren = children.filter((s) => {
+ if (s.parentID && keepRootIds.has(s.parentID)) return true
+ const perms = options.permission[s.id] ?? []
+ if (perms.length > 0) return true
+ return sessionUpdatedAt(s) > cutoff
+ })
+ return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
+}
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
new file mode 100644
index 000000000..ade0b973a
--- /dev/null
+++ b/packages/app/src/context/global-sync/types.ts
@@ -0,0 +1,134 @@
+import type {
+ Agent,
+ Command,
+ Config,
+ FileDiff,
+ LspStatus,
+ McpStatus,
+ Message,
+ Part,
+ Path,
+ PermissionRequest,
+ Project,
+ ProviderListResponse,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+ VcsInfo,
+} from "@opencode-ai/sdk/v2/client"
+import type { Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+export type ProjectMeta = {
+ name?: string
+ icon?: {
+ override?: string
+ color?: string
+ }
+ commands?: {
+ start?: string
+ }
+}
+
+export type State = {
+ status: "loading" | "partial" | "complete"
+ agent: Agent[]
+ command: Command[]
+ project: string
+ projectMeta: ProjectMeta | undefined
+ icon: string | undefined
+ provider: ProviderListResponse
+ config: Config
+ path: Path
+ session: Session[]
+ sessionTotal: number
+ session_status: {
+ [sessionID: string]: SessionStatus
+ }
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ permission: {
+ [sessionID: string]: PermissionRequest[]
+ }
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
+ mcp: {
+ [name: string]: McpStatus
+ }
+ lsp: LspStatus[]
+ vcs: VcsInfo | undefined
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+}
+
+export type VcsCache = {
+ store: Store<{ value: VcsInfo | undefined }>
+ setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type MetaCache = {
+ store: Store<{ value: ProjectMeta | undefined }>
+ setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type IconCache = {
+ store: Store<{ value: string | undefined }>
+ setStore: SetStoreFunction<{ value: string | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type ChildOptions = {
+ bootstrap?: boolean
+}
+
+export type DirState = {
+ lastAccessAt: number
+}
+
+export type EvictPlan = {
+ stores: string[]
+ state: Map<string, DirState>
+ pins: Set<string>
+ max: number
+ ttl: number
+ now: number
+}
+
+export type DisposeCheck = {
+ directory: string
+ hasStore: boolean
+ pinned: boolean
+ booting: boolean
+ loadingSessions: boolean
+}
+
+export type RootLoadArgs = {
+ directory: string
+ limit: number
+ list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+ onFallback: () => void
+}
+
+export type RootLoadResult = {
+ data?: Session[]
+ limit: number
+ limited: boolean
+}
+
+export const MAX_DIR_STORES = 30
+export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
+export const SESSION_RECENT_LIMIT = 50
diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts
new file mode 100644
index 000000000..6b78134a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/utils.ts
@@ -0,0 +1,25 @@
+import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+
+export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
+export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+ return {
+ ...input,
+ all: input.all.map((provider) => ({
+ ...provider,
+ models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
+ })),
+ }
+}
+
+export function sanitizeProject(project: Project) {
+ if (!project.icon?.url && !project.icon?.override) return project
+ return {
+ ...project,
+ icon: {
+ ...project.icon,
+ url: undefined,
+ override: undefined,
+ },
+ }
+}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index bf081996b..22f7bcca1 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [
"th",
]
+type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
+const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
+ zh,
+ zht,
+ ko,
+ de,
+ es,
+ fr,
+ da,
+ ja,
+ pl,
+ ru,
+ ar,
+ no,
+ br,
+ th,
+ bs,
+}
+void PARITY_CHECK
+
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
index c56565385..c421a58b6 100644
--- a/packages/app/src/context/layout-scroll.test.ts
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -1,73 +1,36 @@
import { describe, expect, test } from "bun:test"
-import { createRoot } from "solid-js"
-import { createStore } from "solid-js/store"
-import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => {
- test.skip("debounces persisted scroll writes", async () => {
- const key = "layout-scroll.test"
- const data = new Map<string, string>()
- const writes: string[] = []
- const stats = { flushes: 0 }
-
- const storage = {
- getItem: (k: string) => data.get(k) ?? null,
- setItem: (k: string, v: string) => {
- data.set(k, v)
- if (k === key) writes.push(v)
+ test("debounces persisted scroll writes", async () => {
+ const snapshot = {
+ session: {
+ review: { x: 0, y: 0 },
},
- removeItem: (k: string) => {
- data.delete(k)
+ } as Record<string, Record<string, { x: number; y: number }>>
+ const writes: Array<Record<string, { x: number; y: number }>> = []
+ const scroll = createScrollPersistence({
+ debounceMs: 10,
+ getSnapshot: (sessionKey) => snapshot[sessionKey],
+ onFlush: (sessionKey, next) => {
+ snapshot[sessionKey] = next
+ writes.push(next)
},
- } as SyncStorage
-
- await new Promise<void>((resolve, reject) => {
- createRoot((dispose) => {
- const [raw, setRaw] = createStore({
- sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
- })
-
- const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
-
- const scroll = createScrollPersistence({
- debounceMs: 30,
- getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
- onFlush: (sessionKey, next) => {
- stats.flushes += 1
-
- const current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, { scroll: next })
- return
- }
- setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
- },
- })
+ })
- const run = async () => {
- await new Promise((r) => setTimeout(r, 0))
- writes.length = 0
+ for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
+ scroll.setScroll("session", "review", { x: 0, y: i })
+ }
- for (const i of Array.from({ length: 100 }, (_, n) => n)) {
- scroll.setScroll("session", "review", { x: 0, y: i })
- }
+ await new Promise((resolve) => setTimeout(resolve, 40))
- await new Promise((r) => setTimeout(r, 120))
+ expect(writes).toHaveLength(1)
+ expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
- expect(stats.flushes).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeLessThanOrEqual(2)
- }
+ scroll.setScroll("session", "review", { x: 0, y: 30 })
+ await new Promise((resolve) => setTimeout(resolve, 20))
- void run()
- .then(resolve)
- .catch(reject)
- .finally(() => {
- scroll.dispose()
- dispose()
- })
- })
- })
+ expect(writes).toHaveLength(1)
+ scroll.dispose()
})
})
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index c307f6e72..72693e6ef 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -1,9 +1,9 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
+import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
@@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
- const check = (url: string) => {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => x.data?.healthy === true)
- .catch(() => false)
- }
+ const fetcher = platform.fetch ?? globalThis.fetch
+ const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts
new file mode 100644
index 000000000..7deeddd6e
--- /dev/null
+++ b/packages/app/src/context/sync-optimistic.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
+
+const userMessage = (id: string, sessionID: string): Message => ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+})
+
+const textPart = (id: string, sessionID: string, messageID: string): Part => ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+})
+
+describe("sync optimistic reducers", () => {
+ test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
+ const sessionID = "ses_1"
+ const draft = {
+ message: { [sessionID]: [userMessage("msg_2", sessionID)] },
+ part: {} as Record<string, Part[] | undefined>,
+ }
+
+ applyOptimisticAdd(draft, {
+ sessionID,
+ message: userMessage("msg_1", sessionID),
+ parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
+ })
+
+ expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
+ expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
+ })
+
+ test("applyOptimisticRemove removes message and part entries", () => {
+ const sessionID = "ses_1"
+ const draft = {
+ message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
+ part: {
+ msg_1: [textPart("prt_1", sessionID, "msg_1")],
+ msg_2: [textPart("prt_2", sessionID, "msg_2")],
+ } as Record<string, Part[] | undefined>,
+ }
+
+ applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
+
+ expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
+ expect(draft.part.msg_1).toBeUndefined()
+ expect(draft.part.msg_2).toHaveLength(1)
+ })
+})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 0c6365245..66c53dc80 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+type OptimisticStore = {
+ message: Record<string, Message[] | undefined>
+ part: Record<string, Part[] | undefined>
+}
+
+type OptimisticAddInput = {
+ sessionID: string
+ message: Message
+ parts: Part[]
+}
+
+type OptimisticRemoveInput = {
+ sessionID: string
+ messageID: string
+}
+
+export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
+ const messages = draft.message[input.sessionID]
+ if (!messages) {
+ draft.message[input.sessionID] = [input.message]
+ }
+ if (messages) {
+ const result = Binary.search(messages, input.message.id, (m) => m.id)
+ messages.splice(result.index, 0, input.message)
+ }
+ draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+}
+
+export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
+ const messages = draft.message[input.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[input.messageID]
+}
+
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
+ const target = (directory?: string) => {
+ if (!directory || directory === sdk.directory) return current()
+ return globalSync.child(directory)
+ }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400
const inflight = new Map<string, Promise<void>>()
@@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
session: {
get: getSession,
+ optimistic: {
+ add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+ const [, setStore] = target(input.directory)
+ setStore(
+ produce((draft) => {
+ applyOptimisticAdd(draft as OptimisticStore, input)
+ }),
+ )
+ },
+ remove(input: { directory?: string; sessionID: string; messageID: string }) {
+ const [, setStore] = target(input.directory)
+ setStore(
+ produce((draft) => {
+ applyOptimisticRemove(draft as OptimisticStore, input)
+ }),
+ )
+ },
+ },
addOptimisticMessage(input: {
sessionID: string
messageID: string
@@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: input.agent,
model: input.model,
}
- current()[1](
+ const [, setStore] = target()
+ setStore(
produce((draft) => {
- const messages = draft.message[input.sessionID]
- if (!messages) {
- draft.message[input.sessionID] = [message]
- } else {
- const result = Binary.search(messages, input.messageID, (m) => m.id)
- messages.splice(result.index, 0, message)
- }
- draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
+ applyOptimisticAdd(draft as OptimisticStore, {
+ sessionID: input.sessionID,
+ message,
+ parts: input.parts,
+ })
}),
)
},
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 35f805dbc..77a3edb06 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "فتح الإعدادات",
"command.session.previous": "الجلسة السابقة",
"command.session.next": "الجلسة التالية",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
+ "command.session.next.unseen": "الجلسة غير المقروءة التالية",
"command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index dc8969f7b..a743a3d89 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Abrir configurações",
"command.session.previous": "Sessão anterior",
"command.session.next": "Próxima sessão",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Sessão não lida anterior",
+ "command.session.next.unseen": "Próxima sessão não lida",
"command.session.archive": "Arquivar sessão",
"command.palette": "Paleta de comandos",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 106ddcf6f..88704607b 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Åbn indstillinger",
"command.session.previous": "Forrige session",
"command.session.next": "Næste session",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Forrige ulæste session",
+ "command.session.next.unseen": "Næste ulæste session",
"command.session.archive": "Arkivér session",
"command.palette": "Kommandopalette",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index a240e5475..a4d12d445 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "Einstellungen öffnen",
"command.session.previous": "Vorherige Sitzung",
"command.session.next": "Nächste Sitzung",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Vorherige ungelesene Sitzung",
+ "command.session.next.unseen": "Nächste ungelesene Sitzung",
"command.session.archive": "Sitzung archivieren",
"command.palette": "Befehlspalette",
@@ -147,6 +147,44 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} verbunden",
"provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.",
+ "provider.custom.title": "Benutzerdefinierter Anbieter",
+ "provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ",
+ "provider.custom.description.link": "Anbieter-Konfigurationsdokumente",
+ "provider.custom.description.suffix": ".",
+ "provider.custom.field.providerID.label": "Anbieter-ID",
+ "provider.custom.field.providerID.placeholder": "myprovider",
+ "provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
+ "provider.custom.field.name.label": "Anzeigename",
+ "provider.custom.field.name.placeholder": "Mein KI-Anbieter",
+ "provider.custom.field.baseURL.label": "Basis-URL",
+ "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+ "provider.custom.field.apiKey.label": "API-Schlüssel",
+ "provider.custom.field.apiKey.placeholder": "API-Schlüssel",
+ "provider.custom.field.apiKey.description":
+ "Optional. Leer lassen, wenn Sie die Authentifizierung über Header verwalten.",
+ "provider.custom.models.label": "Modelle",
+ "provider.custom.models.id.label": "ID",
+ "provider.custom.models.id.placeholder": "model-id",
+ "provider.custom.models.name.label": "Name",
+ "provider.custom.models.name.placeholder": "Anzeigename",
+ "provider.custom.models.remove": "Modell entfernen",
+ "provider.custom.models.add": "Modell hinzufügen",
+ "provider.custom.headers.label": "Header (optional)",
+ "provider.custom.headers.key.label": "Header",
+ "provider.custom.headers.key.placeholder": "Header-Name",
+ "provider.custom.headers.value.label": "Wert",
+ "provider.custom.headers.value.placeholder": "wert",
+ "provider.custom.headers.remove": "Header entfernen",
+ "provider.custom.headers.add": "Header hinzufügen",
+ "provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich",
+ "provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
+ "provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits",
+ "provider.custom.error.name.required": "Anzeigename ist erforderlich",
+ "provider.custom.error.baseURL.required": "Basis-URL ist erforderlich",
+ "provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen",
+ "provider.custom.error.required": "Erforderlich",
+ "provider.custom.error.duplicate": "Duplikat",
+
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos",
@@ -380,6 +418,7 @@ export const dict = {
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
+ "directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
"error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 32c4695db..4d7d571af 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -149,6 +149,43 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
+ "provider.custom.title": "Custom provider",
+ "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
+ "provider.custom.description.link": "provider config docs",
+ "provider.custom.description.suffix": ".",
+ "provider.custom.field.providerID.label": "Provider ID",
+ "provider.custom.field.providerID.placeholder": "myprovider",
+ "provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
+ "provider.custom.field.name.label": "Display name",
+ "provider.custom.field.name.placeholder": "My AI Provider",
+ "provider.custom.field.baseURL.label": "Base URL",
+ "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+ "provider.custom.field.apiKey.label": "API key",
+ "provider.custom.field.apiKey.placeholder": "API key",
+ "provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.",
+ "provider.custom.models.label": "Models",
+ "provider.custom.models.id.label": "ID",
+ "provider.custom.models.id.placeholder": "model-id",
+ "provider.custom.models.name.label": "Name",
+ "provider.custom.models.name.placeholder": "Display Name",
+ "provider.custom.models.remove": "Remove model",
+ "provider.custom.models.add": "Add model",
+ "provider.custom.headers.label": "Headers (optional)",
+ "provider.custom.headers.key.label": "Header",
+ "provider.custom.headers.key.placeholder": "Header-Name",
+ "provider.custom.headers.value.label": "Value",
+ "provider.custom.headers.value.placeholder": "value",
+ "provider.custom.headers.remove": "Remove header",
+ "provider.custom.headers.add": "Add header",
+ "provider.custom.error.providerID.required": "Provider ID is required",
+ "provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores",
+ "provider.custom.error.providerID.exists": "That provider ID already exists",
+ "provider.custom.error.name.required": "Display name is required",
+ "provider.custom.error.baseURL.required": "Base URL is required",
+ "provider.custom.error.baseURL.format": "Must start with http:// or https://",
+ "provider.custom.error.required": "Required",
+ "provider.custom.error.duplicate": "Duplicate",
+
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
@@ -404,6 +441,7 @@ export const dict = {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
+ "directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Unknown error",
"error.chain.causedBy": "Caused by:",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index c94f407c6..5d48ba494 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Abrir ajustes",
"command.session.previous": "Sesión anterior",
"command.session.next": "Siguiente sesión",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Sesión no leída anterior",
+ "command.session.next.unseen": "Siguiente sesión no leída",
"command.session.archive": "Archivar sesión",
"command.palette": "Paleta de comandos",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index f36d22804..a76e57ff1 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Ouvrir les paramètres",
"command.session.previous": "Session précédente",
"command.session.next": "Session suivante",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Session non lue précédente",
+ "command.session.next.unseen": "Session non lue suivante",
"command.session.archive": "Archiver la session",
"command.palette": "Palette de commandes",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index c4ce4c40d..e41dea9dc 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "設定を開く",
"command.session.previous": "前のセッション",
"command.session.next": "次のセッション",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "前の未読セッション",
+ "command.session.next.unseen": "次の未読セッション",
"command.session.archive": "セッションをアーカイブ",
"command.palette": "コマンドパレット",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 2a3f4ef81..a4f42a583 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "설정 열기",
"command.session.previous": "이전 세션",
"command.session.next": "다음 세션",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "이전 읽지 않은 세션",
+ "command.session.next.unseen": "다음 읽지 않은 세션",
"command.session.archive": "세션 보관",
"command.palette": "명령 팔레트",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 315b21f2c..3de7837f8 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -31,8 +31,8 @@ export const dict = {
"command.settings.open": "Åpne innstillinger",
"command.session.previous": "Forrige sesjon",
"command.session.next": "Neste sesjon",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Forrige uleste økt",
+ "command.session.next.unseen": "Neste uleste økt",
"command.session.archive": "Arkiver sesjon",
"command.palette": "Kommandopalett",
diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts
new file mode 100644
index 000000000..a75dbd3a3
--- /dev/null
+++ b/packages/app/src/i18n/parity.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test"
+import { dict as en } from "./en"
+import { dict as ar } from "./ar"
+import { dict as br } from "./br"
+import { dict as bs } from "./bs"
+import { dict as da } from "./da"
+import { dict as de } from "./de"
+import { dict as es } from "./es"
+import { dict as fr } from "./fr"
+import { dict as ja } from "./ja"
+import { dict as ko } from "./ko"
+import { dict as no } from "./no"
+import { dict as pl } from "./pl"
+import { dict as ru } from "./ru"
+import { dict as th } from "./th"
+import { dict as zh } from "./zh"
+import { dict as zht } from "./zht"
+
+const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
+const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
+
+describe("i18n parity", () => {
+ test("non-English locales translate targeted unseen session keys", () => {
+ for (const locale of locales) {
+ for (const key of keys) {
+ expect(locale[key]).toBeDefined()
+ expect(locale[key]).not.toBe(en[key])
+ }
+ }
+ })
+})
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 46a727448..44bc4677b 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Otwórz ustawienia",
"command.session.previous": "Poprzednia sesja",
"command.session.next": "Następna sesja",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Poprzednia nieprzeczytana sesja",
+ "command.session.next.unseen": "Następna nieprzeczytana sesja",
"command.session.archive": "Zarchiwizuj sesję",
"command.palette": "Paleta poleceń",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index e4f8b1eaa..28785c0e9 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Открыть настройки",
"command.session.previous": "Предыдущая сессия",
"command.session.next": "Следующая сессия",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "Предыдущая непрочитанная сессия",
+ "command.session.next.unseen": "Следующая непрочитанная сессия",
"command.session.archive": "Архивировать сессию",
"command.palette": "Палитра команд",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index c81b1dff3..9858f39d7 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "เปิดการตั้งค่า",
"command.session.previous": "เซสชันก่อนหน้า",
"command.session.next": "เซสชันถัดไป",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า",
+ "command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
"command.session.archive": "จัดเก็บเซสชัน",
"command.palette": "คำสั่งค้นหา",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index c3b87525c..a8fda6f3a 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "打开设置",
"command.session.previous": "上一个会话",
"command.session.next": "下一个会话",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "上一个未读会话",
+ "command.session.next.unseen": "下一个未读会话",
"command.session.archive": "归档会话",
"command.palette": "命令面板",
@@ -147,6 +147,43 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} 已连接",
"provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。",
+ "provider.custom.title": "自定义提供商",
+ "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看",
+ "provider.custom.description.link": "提供商配置文档",
+ "provider.custom.description.suffix": "。",
+ "provider.custom.field.providerID.label": "提供商 ID",
+ "provider.custom.field.providerID.placeholder": "myprovider",
+ "provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线",
+ "provider.custom.field.name.label": "显示名称",
+ "provider.custom.field.name.placeholder": "我的 AI 提供商",
+ "provider.custom.field.baseURL.label": "基础 URL",
+ "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+ "provider.custom.field.apiKey.label": "API 密钥",
+ "provider.custom.field.apiKey.placeholder": "API 密钥",
+ "provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。",
+ "provider.custom.models.label": "模型",
+ "provider.custom.models.id.label": "ID",
+ "provider.custom.models.id.placeholder": "model-id",
+ "provider.custom.models.name.label": "名称",
+ "provider.custom.models.name.placeholder": "显示名称",
+ "provider.custom.models.remove": "移除模型",
+ "provider.custom.models.add": "添加模型",
+ "provider.custom.headers.label": "请求头(可选)",
+ "provider.custom.headers.key.label": "请求头",
+ "provider.custom.headers.key.placeholder": "Header-Name",
+ "provider.custom.headers.value.label": "值",
+ "provider.custom.headers.value.placeholder": "value",
+ "provider.custom.headers.remove": "移除请求头",
+ "provider.custom.headers.add": "添加请求头",
+ "provider.custom.error.providerID.required": "提供商 ID 为必填项",
+ "provider.custom.error.providerID.format": "请使用小写字母、数字、连字符或下划线",
+ "provider.custom.error.providerID.exists": "该提供商 ID 已存在",
+ "provider.custom.error.name.required": "显示名称为必填项",
+ "provider.custom.error.baseURL.required": "基础 URL 为必填项",
+ "provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头",
+ "provider.custom.error.required": "必填",
+ "provider.custom.error.duplicate": "重复",
+
"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费",
@@ -380,6 +417,7 @@ export const dict = {
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
+ "directory.error.invalidUrl": "URL 中的目录无效。",
"error.chain.unknown": "未知错误",
"error.chain.causedBy": "原因:",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 7be29f036..319f5c51d 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "開啟設定",
"command.session.previous": "上一個工作階段",
"command.session.next": "下一個工作階段",
- "command.session.previous.unseen": "Previous unread session",
- "command.session.next.unseen": "Next unread session",
+ "command.session.previous.unseen": "上一個未讀會話",
+ "command.session.next.unseen": "下一個未讀會話",
"command.session.archive": "封存工作階段",
"command.palette": "命令面板",
@@ -144,6 +144,43 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} 已連線",
"provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。",
+ "provider.custom.title": "自訂提供商",
+ "provider.custom.description.prefix": "設定與 OpenAI 相容的提供商。請參閱",
+ "provider.custom.description.link": "提供商設定文件",
+ "provider.custom.description.suffix": "。",
+ "provider.custom.field.providerID.label": "提供商 ID",
+ "provider.custom.field.providerID.placeholder": "myprovider",
+ "provider.custom.field.providerID.description": "使用小寫字母、數字、連字號或底線",
+ "provider.custom.field.name.label": "顯示名稱",
+ "provider.custom.field.name.placeholder": "我的 AI 提供商",
+ "provider.custom.field.baseURL.label": "基礎 URL",
+ "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+ "provider.custom.field.apiKey.label": "API 金鑰",
+ "provider.custom.field.apiKey.placeholder": "API 金鑰",
+ "provider.custom.field.apiKey.description": "選填。若您透過標頭管理驗證,可留空。",
+ "provider.custom.models.label": "模型",
+ "provider.custom.models.id.label": "ID",
+ "provider.custom.models.id.placeholder": "model-id",
+ "provider.custom.models.name.label": "名稱",
+ "provider.custom.models.name.placeholder": "顯示名稱",
+ "provider.custom.models.remove": "移除模型",
+ "provider.custom.models.add": "新增模型",
+ "provider.custom.headers.label": "標頭(選填)",
+ "provider.custom.headers.key.label": "標頭",
+ "provider.custom.headers.key.placeholder": "Header-Name",
+ "provider.custom.headers.value.label": "值",
+ "provider.custom.headers.value.placeholder": "value",
+ "provider.custom.headers.remove": "移除標頭",
+ "provider.custom.headers.add": "新增標頭",
+ "provider.custom.error.providerID.required": "提供商 ID 為必填",
+ "provider.custom.error.providerID.format": "請使用小寫字母、數字、連字號或底線",
+ "provider.custom.error.providerID.exists": "該提供商 ID 已存在",
+ "provider.custom.error.name.required": "顯示名稱為必填",
+ "provider.custom.error.baseURL.required": "基礎 URL 為必填",
+ "provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭",
+ "provider.custom.error.required": "必填",
+ "provider.custom.error.duplicate": "重複",
+
"provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免費",
@@ -377,6 +414,7 @@ export const dict = {
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
+ "directory.error.invalidUrl": "URL 中的目錄無效。",
"error.chain.unknown": "未知錯誤",
"error.chain.causedBy": "原因:",
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index da4667a82..2f4db8564 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
- description: "Invalid directory in URL.",
+ description: language.t("directory.error.invalidUrl"),
})
navigate("/")
})
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 3b66258c9..59adef469 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2,53 +2,34 @@ import {
batch,
createEffect,
createMemo,
- createSignal,
For,
- Match,
on,
onCleanup,
onMount,
ParentProps,
Show,
- Switch,
untrack,
- type Accessor,
type JSX,
} from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
-import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
+import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
-import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { HoverCard } from "@opencode-ai/ui/hover-card"
-import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { ContextMenu } from "@opencode-ai/ui/context-menu"
-import { Collapsible } from "@opencode-ai/ui/collapsible"
-import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
-import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { Session, type Message } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
-} from "@thisbeyond/solid-dnd"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
@@ -60,7 +41,6 @@ import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { Worktree as WorktreeState } from "@/utils/worktree"
-import { agentColor } from "@/utils/agent"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -75,44 +55,26 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
-
-const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
-const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
-
-function sortSessions(now: number) {
- const oneMinuteAgo = now - 60 * 1000
- return (a: Session, b: Session) => {
- const aUpdated = a.time.updated ?? a.time.created
- const bUpdated = b.time.updated ?? b.time.created
- const aRecent = aUpdated > oneMinuteAgo
- const bRecent = bUpdated > oneMinuteAgo
- if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
- if (aRecent && !bRecent) return -1
- if (!aRecent && bRecent) return 1
- return bUpdated - aUpdated
- }
-}
-
-const isRootVisibleSession = (session: Session, directory: string) =>
- workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
-
-const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
- store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
-
-const childMapByParent = (sessions: Session[]) => {
- const map = new Map<string, string[]>()
- for (const session of sessions) {
- if (!session.parentID) continue
- const existing = map.get(session.parentID)
- if (existing) {
- existing.push(session.id)
- continue
- }
- map.set(session.parentID, [session.id])
- }
- return map
-}
+import {
+ childMapByParent,
+ displayName,
+ errorMessage,
+ getDraggableId,
+ sortedRootSessions,
+ syncWorkspaceOrder,
+ workspaceKey,
+} from "./layout/helpers"
+import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
+import { createInlineEditorController } from "./layout/inline-editor"
+import {
+ LocalWorkspace,
+ SortableWorkspace,
+ WorkspaceDragOverlay,
+ type WorkspaceSidebarContext,
+} from "./layout/sidebar-workspace"
+import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
+import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
+import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@@ -168,10 +130,7 @@ export default function Layout(props: ParentProps) {
nav: undefined as HTMLElement | undefined,
})
- const [editor, setEditor] = createStore({
- active: "" as string,
- value: "",
- })
+ const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory)
setState("busyWorkspaces", (prev) => {
@@ -202,6 +161,8 @@ export default function Layout(props: ParentProps) {
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
+ const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined))
+ const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const hoverProjectData = createMemo(() => {
const id = state.hoverProject
@@ -253,99 +214,11 @@ export default function Layout(props: ParentProps) {
setState("autoselect", false)
})
- const editorOpen = (id: string) => editor.active === id
- const editorValue = () => editor.value
-
- const openEditor = (id: string, value: string) => {
- if (!id) return
- setEditor({ active: id, value })
- }
-
- const closeEditor = () => setEditor({ active: "", value: "" })
-
- const saveEditor = (callback: (next: string) => void) => {
- const next = editor.value.trim()
- if (!next) {
- closeEditor()
- return
- }
- closeEditor()
- callback(next)
- }
-
- const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
- if (event.key === "Enter") {
- event.preventDefault()
- saveEditor(callback)
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeEditor()
- }
- }
-
- const InlineEditor = (props: {
- id: string
- value: Accessor<string>
- onSave: (next: string) => void
- class?: string
- displayClass?: string
- editing?: boolean
- stopPropagation?: boolean
- openOnDblClick?: boolean
- }) => {
- const isEditing = () => props.editing ?? editorOpen(props.id)
- const stopEvents = () => props.stopPropagation ?? false
- const allowDblClick = () => props.openOnDblClick ?? true
- const stopPropagation = (event: Event) => {
- if (!stopEvents()) return
- event.stopPropagation()
- }
- const handleDblClick = (event: MouseEvent) => {
- if (!allowDblClick()) return
- stopPropagation(event)
- openEditor(props.id, props.value())
- }
-
- return (
- <Show
- when={isEditing()}
- fallback={
- <span
- class={props.displayClass ?? props.class}
- onDblClick={handleDblClick}
- onPointerDown={stopPropagation}
- onMouseDown={stopPropagation}
- onClick={stopPropagation}
- onTouchStart={stopPropagation}
- >
- {props.value()}
- </span>
- }
- >
- <InlineInput
- ref={(el) => {
- requestAnimationFrame(() => el.focus())
- }}
- value={editorValue()}
- class={props.class}
- onInput={(event) => setEditor("value", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- editorKeyDown(event, props.onSave)
- }}
- onBlur={() => closeEditor()}
- onPointerDown={stopPropagation}
- onClick={stopPropagation}
- onDblClick={stopPropagation}
- onMouseDown={stopPropagation}
- onMouseUp={stopPropagation}
- onTouchStart={stopPropagation}
- />
- </Show>
- )
- }
+ const editorOpen = editor.editorOpen
+ const openEditor = editor.openEditor
+ const closeEditor = editor.closeEditor
+ const setEditor = editor.setEditor
+ const InlineEditor = editor.InlineEditor
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
@@ -670,15 +543,12 @@ export default function Layout(props: ParentProps) {
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
+ const merged = syncWorkspaceOrder(local, dirs, existing)
if (!existing) {
- setStore("workspaceOrder", project.worktree, dirs)
+ setStore("workspaceOrder", project.worktree, merged)
return
}
- const keep = existing.filter((d) => d !== local && dirs.includes(d))
- const missing = dirs.filter((d) => d !== local && !existing.includes(d))
- const merged = [local, ...missing, ...keep]
-
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
return
@@ -1241,33 +1111,13 @@ export default function Layout(props: ParentProps) {
if (navigate) navigateToProject(directory)
}
- const deepLinkEvent = "opencode:deep-link"
-
- const parseDeepLink = (input: string) => {
- if (!input.startsWith("opencode://")) return
- const url = new URL(input)
- if (url.hostname !== "open-project") return
- const directory = url.searchParams.get("directory")
- if (!directory) return
- return directory
- }
-
const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return
- for (const input of urls) {
- const directory = parseDeepLink(input)
- if (!directory) continue
+ for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory)
}
}
- const drainDeepLinks = () => {
- const pending = window.__OPENCODE__?.deepLinks ?? []
- if (pending.length === 0) return
- if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = []
- handleDeepLinks(pending)
- }
-
onMount(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ urls: string[] }>).detail
@@ -1276,13 +1126,11 @@ export default function Layout(props: ParentProps) {
handleDeepLinks(urls)
}
- drainDeepLinks()
+ handleDeepLinks(drainPendingDeepLinks(window))
window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
})
- const displayName = (project: LocalProject) => project.name || getFilename(project.worktree)
-
async function renameProject(project: LocalProject, next: string) {
const current = displayName(project)
if (next === current) return
@@ -1310,6 +1158,18 @@ export default function Layout(props: ParentProps) {
else navigate("/")
}
+ function toggleProjectWorkspaces(project: LocalProject) {
+ const enabled = layout.sidebar.workspaces(project.worktree)()
+ if (enabled) {
+ layout.sidebar.toggleWorkspaces(project.worktree)
+ return
+ }
+ if (project.vcs !== "git") return
+ layout.sidebar.toggleWorkspaces(project.worktree)
+ }
+
+ const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
+
async function chooseProject() {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
@@ -1336,15 +1196,6 @@ export default function Layout(props: ParentProps) {
}
}
- const errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
-
const deleteWorkspace = async (root: string, directory: string) => {
if (directory === root) return
@@ -1356,7 +1207,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
@@ -1395,7 +1246,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.reset.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
@@ -1622,14 +1473,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.loadSessions(project.worktree)
})
- function getDraggableId(event: unknown): string | undefined {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
- }
-
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
@@ -1665,9 +1508,8 @@ export default function Layout(props: ParentProps) {
const existing = store.workspaceOrder[project.worktree]
if (!existing) return extra ? [...dirs, extra] : dirs
- const keep = existing.filter((d) => d !== local && dirs.includes(d))
- const missing = dirs.filter((d) => d !== local && !existing.includes(d))
- const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
+ const merged = syncWorkspaceOrder(local, dirs, existing)
+ if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
@@ -1710,830 +1552,6 @@ export default function Layout(props: ParentProps) {
setStore("activeWorkspace", undefined)
}
- const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
- const notification = useNotification()
- const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
- const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
- const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
- return (
- <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
- <div class="size-full rounded overflow-clip">
- <Avatar
- fallback={name()}
- src={
- props.project.id === OPENCODE_PROJECT_ID
- ? "https://opencode.ai/favicon.svg"
- : props.project.icon?.override
- }
- {...getAvatarColors(props.project.icon?.color)}
- class="size-full rounded"
- classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
- />
- </div>
- <Show when={unseenCount() > 0 && props.notify}>
- <div
- classList={{
- "absolute top-px right-px size-1.5 rounded-full z-10": true,
- "bg-icon-critical-base": hasError(),
- "bg-text-interactive-base": !hasError(),
- }}
- />
- </Show>
- </div>
- )
- }
-
- const SessionItem = (props: {
- session: Session
- slug: string
- mobile?: boolean
- dense?: boolean
- popover?: boolean
- children: Map<string, string[]>
- }): JSX.Element => {
- const notification = useNotification()
- const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
- const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
- const [sessionStore] = globalSync.child(props.session.directory)
- const hasPermissions = createMemo(() => {
- const permissions = sessionStore.permission?.[props.session.id] ?? []
- if (permissions.length > 0) return true
-
- for (const id of props.children.get(props.session.id) ?? []) {
- const childPermissions = sessionStore.permission?.[id] ?? []
- if (childPermissions.length > 0) return true
- }
- return false
- })
- const isWorking = createMemo(() => {
- if (hasPermissions()) return false
- const status = sessionStore.session_status[props.session.id]
- return status?.type === "busy" || status?.type === "retry"
- })
-
- const tint = createMemo(() => {
- const messages = sessionStore.message[props.session.id]
- if (!messages) return undefined
- let user: Message | undefined
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i]
- if (message.role !== "user") continue
- user = message
- break
- }
- if (!user?.agent) return undefined
-
- const agent = sessionStore.agent.find((a) => a.name === user.agent)
- return agentColor(user.agent, agent?.color)
- })
-
- const hoverMessages = createMemo(() =>
- sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
- )
- const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
- const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
- const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
- const isActive = createMemo(() => props.session.id === params.id)
-
- const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
- const cancelHoverPrefetch = () => {
- if (hoverPrefetch.current === undefined) return
- clearTimeout(hoverPrefetch.current)
- hoverPrefetch.current = undefined
- }
- const scheduleHoverPrefetch = () => {
- if (hoverPrefetch.current !== undefined) return
- hoverPrefetch.current = setTimeout(() => {
- hoverPrefetch.current = undefined
- prefetchSession(props.session)
- }, 200)
- }
-
- onCleanup(cancelHoverPrefetch)
-
- const messageLabel = (message: Message) => {
- const parts = sessionStore.part[message.id] ?? []
- const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
- return text?.text
- }
-
- const item = (
- <A
- href={`${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onPointerEnter={scheduleHoverPrefetch}
- onPointerLeave={cancelHoverPrefetch}
- onMouseEnter={scheduleHoverPrefetch}
- onMouseLeave={cancelHoverPrefetch}
- onFocus={() => prefetchSession(props.session, "high")}
- onClick={() => {
- setState("hoverSession", undefined)
- if (layout.sidebar.opened()) return
- queueMicrotask(() => setState("hoverProject", undefined))
- }}
- >
- <div class="flex items-center gap-1 w-full">
- <div
- class="shrink-0 size-6 flex items-center justify-center"
- style={{ color: tint() ?? "var(--icon-interactive-base)" }}
- >
- <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
- <Match when={isWorking()}>
- <Spinner class="size-[15px]" />
- </Match>
- <Match when={hasPermissions()}>
- <div class="size-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={hasError()}>
- <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={unseenCount() > 0}>
- <div class="size-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- </Switch>
- </div>
- <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
- {props.session.title}
- </span>
- <Show when={props.session.summary}>
- {(summary) => (
- <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
- <DiffChanges changes={summary()} />
- </div>
- )}
- </Show>
- </div>
- </A>
- )
-
- return (
- <div
- data-session-id={props.session.id}
- class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
- hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
- >
- <Show
- when={hoverEnabled()}
- fallback={
- <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
- {item}
- </Tooltip>
- }
- >
- <HoverCard
- openDelay={1000}
- closeDelay={sidebarHovering() ? 600 : 0}
- placement="right-start"
- gutter={16}
- shift={-2}
- trigger={item}
- mount={!props.mobile ? state.nav : undefined}
- open={state.hoverSession === props.session.id}
- onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)}
- >
- <Show
- when={hoverReady()}
- fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
- >
- <div class="overflow-y-auto max-h-72 h-full">
- <MessageNav
- messages={hoverMessages() ?? []}
- current={undefined}
- getLabel={messageLabel}
- onMessageSelect={(message) => {
- if (!isActive()) {
- layout.pendingMessage.set(
- `${base64Encode(props.session.directory)}/${props.session.id}`,
- message.id,
- )
- navigate(`${props.slug}/session/${props.session.id}`)
- return
- }
- window.history.replaceState(null, "", `#message-${message.id}`)
- window.dispatchEvent(new HashChangeEvent("hashchange"))
- }}
- size="normal"
- class="w-60"
- />
- </div>
- </Show>
- </HoverCard>
- </Show>
- <div
- class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
- classList={{
- "opacity-100 pointer-events-auto": !!props.mobile,
- "opacity-0 pointer-events-none": !props.mobile,
- "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
- "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
- }}
- >
- <Tooltip value={language.t("common.archive")} placement="top">
- <IconButton
- icon="archive"
- variant="ghost"
- class="size-6 rounded-md"
- aria-label={language.t("common.archive")}
- onClick={(event) => {
- event.preventDefault()
- event.stopPropagation()
- void archiveSession(props.session)
- }}
- />
- </Tooltip>
- </div>
- </div>
- )
- }
-
- const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
- const label = language.t("command.session.new")
- const tooltip = () => props.mobile || !sidebarExpanded()
- const item = (
- <A
- href={`${props.slug}/session`}
- end
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
- onClick={() => {
- setState("hoverSession", undefined)
- if (layout.sidebar.opened()) return
- queueMicrotask(() => setState("hoverProject", undefined))
- }}
- >
- <div class="flex items-center gap-1 w-full">
- <div class="shrink-0 size-6 flex items-center justify-center">
- <Icon name="plus-small" size="small" class="text-icon-weak" />
- </div>
- <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
- {label}
- </span>
- </div>
- </A>
- )
-
- return (
- <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
- <Show
- when={!tooltip()}
- fallback={
- <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
- {item}
- </Tooltip>
- }
- >
- {item}
- </Show>
- </div>
- )
- }
-
- const SessionSkeleton = (props: { count?: number }): JSX.Element => {
- const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
- return (
- <div class="flex flex-col gap-1">
- <For each={items}>
- {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
- </For>
- </div>
- )
- }
-
- const ProjectDragOverlay = (): JSX.Element => {
- const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
- return (
- <Show when={project()}>
- {(p) => (
- <div class="bg-background-base rounded-xl p-1">
- <ProjectIcon project={p()} />
- </div>
- )}
- </Show>
- )
- }
-
- const WorkspaceDragOverlay = (): JSX.Element => {
- const label = createMemo(() => {
- const project = sidebarProject()
- if (!project) return
- const directory = store.activeWorkspace
- if (!directory) return
-
- const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
- const kind =
- directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
- return `${kind} : ${name}`
- })
-
- return (
- <Show when={label()}>
- {(value) => (
- <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
- )}
- </Show>
- )
- }
-
- const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.directory)
- const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
- const [menu, setMenu] = createStore({
- open: false,
- pendingRename: false,
- })
- const slug = createMemo(() => base64Encode(props.directory))
- const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
- const children = createMemo(() => childMapByParent(workspaceStore.session))
- const local = createMemo(() => props.directory === props.project.worktree)
- const active = createMemo(() => currentDir() === props.directory)
- const workspaceValue = createMemo(() => {
- const branch = workspaceStore.vcs?.branch
- const name = branch ?? getFilename(props.directory)
- return workspaceName(props.directory, props.project.id, branch) ?? name
- })
- const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
- const boot = createMemo(() => open() || active())
- const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
- const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
- const busy = createMemo(() => isBusy(props.directory))
- const wasBusy = createMemo((prev) => prev || busy(), false)
- const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
- const loadMore = async () => {
- setWorkspaceStore("limit", (limit) => limit + 5)
- await globalSync.project.loadSessions(props.directory)
- }
-
- const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`))
-
- const openWrapper = (value: boolean) => {
- setStore("workspaceExpanded", props.directory, value)
- if (value) return
- if (editorOpen(`workspace:${props.directory}`)) closeEditor()
- }
-
- createEffect(() => {
- if (!boot()) return
- globalSync.child(props.directory, { bootstrap: true })
- })
-
- const header = () => (
- <div class="flex items-center gap-1 min-w-0 flex-1">
- <div class="flex items-center justify-center shrink-0 size-6">
- <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
- <Spinner class="size-[15px]" />
- </Show>
- </div>
- <span class="text-14-medium text-text-base shrink-0">
- {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
- </span>
- <Show
- when={!local()}
- fallback={
- <span class="text-14-medium text-text-base min-w-0 truncate">
- {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
- </span>
- }
- >
- <InlineEditor
- id={`workspace:${props.directory}`}
- value={workspaceValue}
- onSave={(next) => {
- const trimmed = next.trim()
- if (!trimmed) return
- renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
- setEditor("value", workspaceValue())
- }}
- class="text-14-medium text-text-base min-w-0 truncate"
- displayClass="text-14-medium text-text-base min-w-0 truncate"
- editing={workspaceEditActive()}
- stopPropagation={false}
- openOnDblClick={false}
- />
- </Show>
- <Icon
- name={open() ? "chevron-down" : "chevron-right"}
- size="small"
- class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
- />
- </div>
- )
-
- return (
- <div
- // @ts-ignore
- use:sortable
- classList={{
- "opacity-30": sortable.isActiveDraggable,
- "opacity-50 pointer-events-none": busy(),
- }}
- >
- <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
- <div class="px-2 py-1">
- <div
- class="group/workspace relative"
- data-component="workspace-item"
- data-workspace={base64Encode(props.directory)}
- >
- <div class="flex items-center gap-1">
- <Show
- when={workspaceEditActive()}
- fallback={
- <Collapsible.Trigger
- class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
- data-action="workspace-toggle"
- data-workspace={base64Encode(props.directory)}
- >
- {header()}
- </Collapsible.Trigger>
- }
- >
- <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
- </Show>
- <div
- class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
- classList={{
- "opacity-100 pointer-events-auto": menu.open,
- "opacity-0 pointer-events-none": !menu.open,
- "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
- "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
- }}
- >
- <DropdownMenu
- modal={!sidebarHovering()}
- open={menu.open}
- onOpenChange={(open) => setMenu("open", open)}
- >
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md"
- data-action="workspace-menu"
- data-workspace={base64Encode(props.directory)}
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!menu.pendingRename) return
- event.preventDefault()
- setMenu("pendingRename", false)
- openEditor(`workspace:${props.directory}`, workspaceValue())
- }}
- >
- <DropdownMenu.Item
- disabled={local()}
- onSelect={() => {
- setMenu("pendingRename", true)
- setMenu("open", false)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() =>
- dialog.show(() => (
- <DialogResetWorkspace root={props.project.worktree} directory={props.directory} />
- ))
- }
- >
- <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() =>
- dialog.show(() => (
- <DialogDeleteWorkspace root={props.project.worktree} directory={props.directory} />
- ))
- }
- >
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- </div>
- </div>
- </div>
-
- <Collapsible.Content>
- <nav class="flex flex-col gap-1 px-2">
- <NewSessionItem slug={slug()} mobile={props.mobile} />
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => (
- <SessionItem session={session} slug={slug()} mobile={props.mobile} children={children()} />
- )}
- </For>
- <Show when={hasMore()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
- size="large"
- onClick={(e: MouseEvent) => {
- loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
- }}
- >
- {language.t("common.loadMore")}
- </Button>
- </div>
- </Show>
- </nav>
- </Collapsible.Content>
- </Collapsible>
- </div>
- )
- }
-
- const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.project.worktree)
- const selected = createMemo(() => {
- const current = currentDir()
- return props.project.worktree === current || props.project.sandboxes?.includes(current)
- })
-
- const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
- const workspaceEnabled = createMemo(
- () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
- )
- const [open, setOpen] = createSignal(false)
- const [menu, setMenu] = createSignal(false)
-
- const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
- const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
- const active = createMemo(
- () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
- )
-
- createEffect(() => {
- if (preview()) return
- if (!open()) return
- setOpen(false)
- })
-
- const label = (directory: string) => {
- const [data] = globalSync.child(directory, { bootstrap: false })
- const kind =
- directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
- return `${kind} : ${name}`
- }
-
- const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
- const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
- const projectChildren = createMemo(() => childMapByParent(projectStore().session))
- const workspaceSessions = (directory: string) => {
- const [data] = globalSync.child(directory, { bootstrap: false })
- return sortedRootSessions(data, Date.now()).slice(0, 2)
- }
- const workspaceChildren = (directory: string) => {
- const [data] = globalSync.child(directory, { bootstrap: false })
- return childMapByParent(data.session)
- }
-
- const projectName = () => props.project.name || getFilename(props.project.worktree)
- const Trigger = () => (
- <ContextMenu
- modal={!sidebarHovering()}
- onOpenChange={(value) => {
- setMenu(value)
- if (value) setOpen(false)
- }}
- >
- <ContextMenu.Trigger
- as="button"
- type="button"
- aria-label={projectName()}
- data-action="project-switch"
- data-project={base64Encode(props.project.worktree)}
- classList={{
- "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
- "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
- "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
- !selected() && !active(),
- "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
- }}
- onMouseEnter={(event: MouseEvent) => {
- if (!overlay()) return
- aim.enter(props.project.worktree, event)
- }}
- onMouseLeave={() => {
- if (!overlay()) return
- aim.leave(props.project.worktree)
- }}
- onFocus={() => {
- if (!overlay()) return
- aim.activate(props.project.worktree)
- }}
- onClick={() => navigateToProject(props.project.worktree)}
- onBlur={() => setOpen(false)}
- >
- <ProjectIcon project={props.project} notify />
- </ContextMenu.Trigger>
- <ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
- <ContextMenu.Content>
- <ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
- <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
- </ContextMenu.Item>
- <ContextMenu.Item
- data-action="project-workspaces-toggle"
- data-project={base64Encode(props.project.worktree)}
- disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
- onSelect={() => {
- const enabled = layout.sidebar.workspaces(props.project.worktree)()
- if (enabled) {
- layout.sidebar.toggleWorkspaces(props.project.worktree)
- return
- }
- if (props.project.vcs !== "git") return
- layout.sidebar.toggleWorkspaces(props.project.worktree)
- }}
- >
- <ContextMenu.ItemLabel>
- {layout.sidebar.workspaces(props.project.worktree)()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </ContextMenu.ItemLabel>
- </ContextMenu.Item>
- <ContextMenu.Separator />
- <ContextMenu.Item
- data-action="project-close-menu"
- data-project={base64Encode(props.project.worktree)}
- onSelect={() => closeProject(props.project.worktree)}
- >
- <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
- </ContextMenu.Item>
- </ContextMenu.Content>
- </ContextMenu.Portal>
- </ContextMenu>
- )
-
- return (
- // @ts-ignore
- <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
- <Show when={preview()} fallback={<Trigger />}>
- <HoverCard
- open={open() && !menu()}
- openDelay={0}
- closeDelay={0}
- placement="right-start"
- gutter={6}
- trigger={<Trigger />}
- onOpenChange={(value) => {
- if (menu()) return
- setOpen(value)
- if (value) setState("hoverSession", undefined)
- }}
- >
- <div class="-m-3 p-2 flex flex-col w-72">
- <div class="px-4 pt-2 pb-1 flex items-center gap-2">
- <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
- <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
- <IconButton
- icon="circle-x"
- variant="ghost"
- class="shrink-0"
- data-action="project-close-hover"
- data-project={base64Encode(props.project.worktree)}
- aria-label={language.t("common.close")}
- onClick={(event) => {
- event.stopPropagation()
- setOpen(false)
- closeProject(props.project.worktree)
- }}
- />
- </Tooltip>
- </div>
- <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
- <div class="px-2 pb-2 flex flex-col gap-2">
- <Show
- when={workspaceEnabled()}
- fallback={
- <For each={projectSessions()}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(props.project.worktree)}
- dense
- mobile={props.mobile}
- popover={false}
- children={projectChildren()}
- />
- )}
- </For>
- }
- >
- <For each={workspaces()}>
- {(directory) => {
- const sessions = createMemo(() => workspaceSessions(directory))
- const children = createMemo(() => workspaceChildren(directory))
- return (
- <div class="flex flex-col gap-1">
- <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
- <div class="shrink-0 size-6 flex items-center justify-center">
- <Icon name="branch" size="small" class="text-icon-base" />
- </div>
- <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
- </div>
- <For each={sessions()}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(directory)}
- dense
- mobile={props.mobile}
- popover={false}
- children={children()}
- />
- )}
- </For>
- </div>
- )
- }}
- </For>
- </Show>
- </div>
- <div class="px-2 py-2 border-t border-border-weak-base">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
- onClick={() => {
- layout.sidebar.open()
- setOpen(false)
- if (selected()) {
- return
- }
- navigateToProject(props.project.worktree)
- }}
- >
- {language.t("sidebar.project.viewAllSessions")}
- </Button>
- </div>
- </div>
- </HoverCard>
- </Show>
- </div>
- )
- }
-
- const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const workspace = createMemo(() => {
- const [store, setStore] = globalSync.child(props.project.worktree)
- return { store, setStore }
- })
- const slug = createMemo(() => base64Encode(props.project.worktree))
- const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
- const children = createMemo(() => childMapByParent(workspace().store.session))
- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
- const loading = createMemo(() => !booted() && sessions().length === 0)
- const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
- const loadMore = async () => {
- workspace().setStore("limit", (limit) => limit + 5)
- await globalSync.project.loadSessions(props.project.worktree)
- }
-
- return (
- <div
- ref={(el) => {
- if (!props.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
- >
- <nav class="flex flex-col gap-1 px-2">
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} children={children()} />}
- </For>
- <Show when={hasMore()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
- size="large"
- onClick={(e: MouseEvent) => {
- loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
- }}
- >
- {language.t("common.loadMore")}
- </Button>
- </div>
- </Show>
- </nav>
- </div>
- )
- }
-
const createWorkspace = async (project: LocalProject) => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
@@ -2545,7 +1563,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
@@ -2579,6 +1597,65 @@ export default function Layout(props: ParentProps) {
layout.mobileSidebar.hide()
}
+ const workspaceSidebarCtx: WorkspaceSidebarContext = {
+ currentDir,
+ sidebarExpanded,
+ sidebarHovering,
+ nav: () => state.nav,
+ hoverSession: () => state.hoverSession,
+ setHoverSession,
+ clearHoverProjectSoon,
+ prefetchSession,
+ archiveSession,
+ workspaceName,
+ renameWorkspace,
+ editorOpen,
+ openEditor,
+ closeEditor,
+ setEditor,
+ InlineEditor,
+ isBusy,
+ workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
+ setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
+ showResetWorkspaceDialog: (root, directory) =>
+ dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
+ showDeleteWorkspaceDialog: (root, directory) =>
+ dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
+ setScrollContainerRef: (el, mobile) => {
+ if (!mobile) scrollContainerRef = el
+ },
+ }
+
+ const projectSidebarCtx: ProjectSidebarContext = {
+ currentDir,
+ sidebarOpened: () => layout.sidebar.opened(),
+ sidebarHovering,
+ hoverProject: () => state.hoverProject,
+ nav: () => state.nav,
+ onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
+ onProjectMouseLeave: (worktree) => aim.leave(worktree),
+ onProjectFocus: (worktree) => aim.activate(worktree),
+ navigateToProject,
+ openSidebar: () => layout.sidebar.open(),
+ closeProject,
+ showEditProjectDialog,
+ toggleProjectWorkspaces,
+ workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
+ workspaceIds,
+ workspaceLabel,
+ sessionProps: {
+ sidebarExpanded,
+ sidebarHovering,
+ nav: () => state.nav,
+ hoverSession: () => state.hoverSession,
+ setHoverSession,
+ clearHoverProjectSoon,
+ prefetchSession,
+ archiveSession,
+ },
+ setHoverSession,
+ }
+
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
const projectName = createMemo(() => {
const project = panelProps.project
@@ -2649,22 +1726,14 @@ export default function Layout(props: ParentProps) {
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
+ <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
- onSelect={() => {
- const enabled = layout.sidebar.workspaces(p().worktree)()
- if (enabled) {
- layout.sidebar.toggleWorkspaces(p().worktree)
- return
- }
- if (p().vcs !== "git") return
- layout.sidebar.toggleWorkspaces(p().worktree)
- }}
+ onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
@@ -2715,7 +1784,7 @@ export default function Layout(props: ParentProps) {
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
- <LocalWorkspace project={p()} mobile={panelProps.mobile} />
+ <LocalWorkspace ctx={workspaceSidebarCtx} project={p()} mobile={panelProps.mobile} />
</div>
</>
}
@@ -2750,13 +1819,22 @@ export default function Layout(props: ParentProps) {
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
- <SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
+ <SortableWorkspace
+ ctx={workspaceSidebarCtx}
+ directory={directory}
+ project={p()}
+ mobile={panelProps.mobile}
+ />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
- <WorkspaceDragOverlay />
+ <WorkspaceDragOverlay
+ sidebarProject={sidebarProject}
+ activeWorkspace={() => store.activeWorkspace}
+ workspaceLabel={workspaceLabel}
+ />
</DragOverlay>
</DragDropProvider>
</div>
@@ -2793,85 +1871,6 @@ export default function Layout(props: ParentProps) {
)
}
- const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
- const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
-
- return (
- <div class="flex h-full w-full overflow-hidden">
- <div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>
- <div class="flex-1 min-h-0 w-full">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
- <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
- <For each={layout.projects.list()}>
- {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
- </For>
- </SortableProvider>
- <Tooltip
- placement={sidebarProps.mobile ? "bottom" : "right"}
- value={
- <div class="flex items-center gap-2">
- <span>{language.t("command.project.open")}</span>
- <Show when={!sidebarProps.mobile}>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </Show>
- </div>
- }
- >
- <IconButton
- icon="plus"
- variant="ghost"
- size="large"
- onClick={chooseProject}
- aria-label={language.t("command.project.open")}
- />
- </Tooltip>
- </div>
- <DragOverlay>
- <ProjectDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
- <TooltipKeybind
- placement={sidebarProps.mobile ? "bottom" : "right"}
- title={language.t("sidebar.settings")}
- keybind={command.keybind("settings.open")}
- >
- <IconButton
- icon="settings-gear"
- variant="ghost"
- size="large"
- onClick={openSettings}
- aria-label={language.t("sidebar.settings")}
- />
- </TooltipKeybind>
- <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value={language.t("sidebar.help")}>
- <IconButton
- icon="help"
- variant="ghost"
- size="large"
- onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- aria-label={language.t("sidebar.help")}
- />
- </Tooltip>
- </div>
- </div>
-
- <Show when={expanded()}>
- <SidebarPanel project={currentProject()} mobile={sidebarProps.mobile} />
- </Show>
- </div>
- )
- }
-
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
@@ -2905,7 +1904,27 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="@container w-full h-full contain-strict">
- <SidebarContent />
+ <SidebarContent
+ opened={() => layout.sidebar.opened()}
+ aimMove={aim.move}
+ projects={() => layout.projects.list()}
+ renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} />}
+ handleDragStart={handleDragStart}
+ handleDragEnd={handleDragEnd}
+ handleDragOver={handleDragOver}
+ openProjectLabel={language.t("command.project.open")}
+ openProjectKeybind={() => command.keybind("project.open")}
+ onOpenProject={chooseProject}
+ renderProjectOverlay={() => (
+ <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
+ )}
+ settingsLabel={() => language.t("sidebar.settings")}
+ settingsKeybind={() => command.keybind("settings.open")}
+ onOpenSettings={openSettings}
+ helpLabel={() => language.t("sidebar.help")}
+ onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+ renderPanel={() => <SidebarPanel project={currentProject()} />}
+ />
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
{(project) => (
@@ -2947,7 +1966,28 @@ export default function Layout(props: ParentProps) {
}}
onClick={(e) => e.stopPropagation()}
>
- <SidebarContent mobile />
+ <SidebarContent
+ mobile
+ opened={() => layout.sidebar.opened()}
+ aimMove={aim.move}
+ projects={() => layout.projects.list()}
+ renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} mobile />}
+ handleDragStart={handleDragStart}
+ handleDragEnd={handleDragEnd}
+ handleDragOver={handleDragOver}
+ openProjectLabel={language.t("command.project.open")}
+ openProjectKeybind={() => command.keybind("project.open")}
+ onOpenProject={chooseProject}
+ renderProjectOverlay={() => (
+ <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
+ )}
+ settingsLabel={() => language.t("sidebar.settings")}
+ settingsKeybind={() => command.keybind("settings.open")}
+ onOpenSettings={openSettings}
+ helpLabel={() => language.t("sidebar.help")}
+ onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+ renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
+ />
</nav>
</div>
diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts
new file mode 100644
index 000000000..772e6ece6
--- /dev/null
+++ b/packages/app/src/pages/layout/deep-links.ts
@@ -0,0 +1,26 @@
+export const deepLinkEvent = "opencode:deep-link"
+
+export const parseDeepLink = (input: string) => {
+ if (!input.startsWith("opencode://")) return
+ const url = new URL(input)
+ if (url.hostname !== "open-project") return
+ const directory = url.searchParams.get("directory")
+ if (!directory) return
+ return directory
+}
+
+export const collectOpenProjectDeepLinks = (urls: string[]) =>
+ urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
+
+type OpenCodeWindow = Window & {
+ __OPENCODE__?: {
+ deepLinks?: string[]
+ }
+}
+
+export const drainPendingDeepLinks = (target: OpenCodeWindow) => {
+ const pending = target.__OPENCODE__?.deepLinks ?? []
+ if (pending.length === 0) return []
+ if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = []
+ return pending
+}
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
new file mode 100644
index 000000000..8a8ea78c7
--- /dev/null
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
+import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
+
+describe("layout deep links", () => {
+ test("parses open-project deep links", () => {
+ expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
+ })
+
+ test("ignores non-project deep links", () => {
+ expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined()
+ expect(parseDeepLink("https://example.com")).toBeUndefined()
+ })
+
+ test("collects only valid open-project directories", () => {
+ const result = collectOpenProjectDeepLinks([
+ "opencode://open-project?directory=/a",
+ "opencode://other?directory=/b",
+ "opencode://open-project?directory=/c",
+ ])
+ expect(result).toEqual(["/a", "/c"])
+ })
+
+ test("drains global deep links once", () => {
+ const target = {
+ __OPENCODE__: {
+ deepLinks: ["opencode://open-project?directory=/a"],
+ },
+ } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } }
+
+ expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"])
+ expect(drainPendingDeepLinks(target)).toEqual([])
+ })
+})
+
+describe("layout workspace helpers", () => {
+ test("normalizes trailing slash in workspace key", () => {
+ expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
+ expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
+ })
+
+ test("keeps local first while preserving known order", () => {
+ const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
+ expect(result).toEqual(["/root", "/c", "/b"])
+ })
+
+ test("extracts draggable id safely", () => {
+ expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
+ expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
+ expect(getDraggableId(null)).toBeUndefined()
+ })
+
+ test("formats fallback project display name", () => {
+ expect(displayName({ worktree: "/tmp/app" })).toBe("app")
+ expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+ })
+
+ test("extracts api error message and fallback", () => {
+ expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom")
+ expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
+ expect(errorMessage("unknown", "fallback")).toBe("fallback")
+ })
+})
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
new file mode 100644
index 000000000..4d144f34e
--- /dev/null
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -0,0 +1,65 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+
+export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
+
+export function sortSessions(now: number) {
+ const oneMinuteAgo = now - 60 * 1000
+ return (a: Session, b: Session) => {
+ const aUpdated = a.time.updated ?? a.time.created
+ const bUpdated = b.time.updated ?? b.time.created
+ const aRecent = aUpdated > oneMinuteAgo
+ const bRecent = bUpdated > oneMinuteAgo
+ if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+ if (aRecent && !bRecent) return -1
+ if (!aRecent && bRecent) return 1
+ return bUpdated - aUpdated
+ }
+}
+
+export const isRootVisibleSession = (session: Session, directory: string) =>
+ workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
+
+export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
+ store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
+
+export const childMapByParent = (sessions: Session[]) => {
+ const map = new Map<string, string[]>()
+ for (const session of sessions) {
+ if (!session.parentID) continue
+ const existing = map.get(session.parentID)
+ if (existing) {
+ existing.push(session.id)
+ continue
+ }
+ map.set(session.parentID, [session.id])
+ }
+ return map
+}
+
+export function getDraggableId(event: unknown): string | undefined {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+}
+
+export const displayName = (project: { name?: string; worktree: string }) =>
+ project.name || getFilename(project.worktree)
+
+export const errorMessage = (err: unknown, fallback: string) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return fallback
+}
+
+export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
+ if (!existing) return dirs
+ const keep = existing.filter((d) => d !== local && dirs.includes(d))
+ const missing = dirs.filter((d) => d !== local && !existing.includes(d))
+ return [local, ...missing, ...keep]
+}
diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx
new file mode 100644
index 000000000..0bbfe244c
--- /dev/null
+++ b/packages/app/src/pages/layout/inline-editor.tsx
@@ -0,0 +1,113 @@
+import { createStore } from "solid-js/store"
+import { Show, type Accessor } from "solid-js"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+
+export function createInlineEditorController() {
+ const [editor, setEditor] = createStore({
+ active: "" as string,
+ value: "",
+ })
+
+ const editorOpen = (id: string) => editor.active === id
+ const editorValue = () => editor.value
+ const openEditor = (id: string, value: string) => {
+ if (!id) return
+ setEditor({ active: id, value })
+ }
+ const closeEditor = () => setEditor({ active: "", value: "" })
+
+ const saveEditor = (callback: (next: string) => void) => {
+ const next = editor.value.trim()
+ if (!next) {
+ closeEditor()
+ return
+ }
+ closeEditor()
+ callback(next)
+ }
+
+ const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
+ if (event.key === "Enter") {
+ event.preventDefault()
+ saveEditor(callback)
+ return
+ }
+ if (event.key !== "Escape") return
+ event.preventDefault()
+ closeEditor()
+ }
+
+ const InlineEditor = (props: {
+ id: string
+ value: Accessor<string>
+ onSave: (next: string) => void
+ class?: string
+ displayClass?: string
+ editing?: boolean
+ stopPropagation?: boolean
+ openOnDblClick?: boolean
+ }) => {
+ const isEditing = () => props.editing ?? editorOpen(props.id)
+ const stopEvents = () => props.stopPropagation ?? false
+ const allowDblClick = () => props.openOnDblClick ?? true
+ const stopPropagation = (event: Event) => {
+ if (!stopEvents()) return
+ event.stopPropagation()
+ }
+ const handleDblClick = (event: MouseEvent) => {
+ if (!allowDblClick()) return
+ stopPropagation(event)
+ openEditor(props.id, props.value())
+ }
+
+ return (
+ <Show
+ when={isEditing()}
+ fallback={
+ <span
+ class={props.displayClass ?? props.class}
+ onDblClick={handleDblClick}
+ onPointerDown={stopPropagation}
+ onMouseDown={stopPropagation}
+ onClick={stopPropagation}
+ onTouchStart={stopPropagation}
+ >
+ {props.value()}
+ </span>
+ }
+ >
+ <InlineInput
+ ref={(el) => {
+ requestAnimationFrame(() => el.focus())
+ }}
+ value={editorValue()}
+ class={props.class}
+ onInput={(event) => setEditor("value", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ editorKeyDown(event, props.onSave)
+ }}
+ onBlur={closeEditor}
+ onPointerDown={stopPropagation}
+ onClick={stopPropagation}
+ onDblClick={stopPropagation}
+ onMouseDown={stopPropagation}
+ onMouseUp={stopPropagation}
+ onTouchStart={stopPropagation}
+ />
+ </Show>
+ )
+ }
+
+ return {
+ editor,
+ editorOpen,
+ editorValue,
+ openEditor,
+ closeEditor,
+ saveEditor,
+ editorKeyDown,
+ setEditor,
+ InlineEditor,
+ }
+}
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
new file mode 100644
index 000000000..facfbddc7
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -0,0 +1,330 @@
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
+import { useNotification } from "@/context/notification"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Avatar } from "@opencode-ai/ui/avatar"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getFilename } from "@opencode-ai/util/path"
+import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
+import { agentColor } from "@/utils/agent"
+
+const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
+ const notification = useNotification()
+ const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
+ const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
+ const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
+ return (
+ <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
+ <div class="size-full rounded overflow-clip">
+ <Avatar
+ fallback={name()}
+ src={
+ props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
+ }
+ {...getAvatarColors(props.project.icon?.color)}
+ class="size-full rounded"
+ classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
+ />
+ </div>
+ <Show when={unseenCount() > 0 && props.notify}>
+ <div
+ classList={{
+ "absolute top-px right-px size-1.5 rounded-full z-10": true,
+ "bg-icon-critical-base": hasError(),
+ "bg-text-interactive-base": !hasError(),
+ }}
+ />
+ </Show>
+ </div>
+ )
+}
+
+export type SessionItemProps = {
+ session: Session
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ popover?: boolean
+ children: Map<string, string[]>
+ sidebarExpanded: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ nav: Accessor<HTMLElement | undefined>
+ hoverSession: Accessor<string | undefined>
+ setHoverSession: (id: string | undefined) => void
+ clearHoverProjectSoon: () => void
+ prefetchSession: (session: Session, priority?: "high" | "low") => void
+ archiveSession: (session: Session) => Promise<void>
+}
+
+export const SessionItem = (props: SessionItemProps): JSX.Element => {
+ const params = useParams()
+ const navigate = useNavigate()
+ const layout = useLayout()
+ const language = useLanguage()
+ const notification = useNotification()
+ const globalSync = useGlobalSync()
+ const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
+ const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
+ const [sessionStore] = globalSync.child(props.session.directory)
+ const hasPermissions = createMemo(() => {
+ const permissions = sessionStore.permission?.[props.session.id] ?? []
+ if (permissions.length > 0) return true
+
+ for (const id of props.children.get(props.session.id) ?? []) {
+ const childPermissions = sessionStore.permission?.[id] ?? []
+ if (childPermissions.length > 0) return true
+ }
+ return false
+ })
+ const isWorking = createMemo(() => {
+ if (hasPermissions()) return false
+ const status = sessionStore.session_status[props.session.id]
+ return status?.type === "busy" || status?.type === "retry"
+ })
+
+ const tint = createMemo(() => {
+ const messages = sessionStore.message[props.session.id]
+ if (!messages) return undefined
+ let user: Message | undefined
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i]
+ if (message.role !== "user") continue
+ user = message
+ break
+ }
+ if (!user?.agent) return undefined
+
+ const agent = sessionStore.agent.find((a) => a.name === user.agent)
+ return agentColor(user.agent, agent?.color)
+ })
+
+ const hoverMessages = createMemo(() =>
+ sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+ )
+ const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+ const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
+ const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
+ const isActive = createMemo(() => props.session.id === params.id)
+
+ const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
+ const cancelHoverPrefetch = () => {
+ if (hoverPrefetch.current === undefined) return
+ clearTimeout(hoverPrefetch.current)
+ hoverPrefetch.current = undefined
+ }
+ const scheduleHoverPrefetch = () => {
+ if (hoverPrefetch.current !== undefined) return
+ hoverPrefetch.current = setTimeout(() => {
+ hoverPrefetch.current = undefined
+ props.prefetchSession(props.session)
+ }, 200)
+ }
+
+ onCleanup(cancelHoverPrefetch)
+
+ const messageLabel = (message: Message) => {
+ const parts = sessionStore.part[message.id] ?? []
+ const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
+ return text?.text
+ }
+
+ const item = (
+ <A
+ href={`${props.slug}/session/${props.session.id}`}
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+ onPointerEnter={scheduleHoverPrefetch}
+ onPointerLeave={cancelHoverPrefetch}
+ onMouseEnter={scheduleHoverPrefetch}
+ onMouseLeave={cancelHoverPrefetch}
+ onFocus={() => props.prefetchSession(props.session, "high")}
+ onClick={() => {
+ props.setHoverSession(undefined)
+ if (layout.sidebar.opened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div
+ class="shrink-0 size-6 flex items-center justify-center"
+ style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+ >
+ <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+ <Match when={isWorking()}>
+ <Spinner class="size-[15px]" />
+ </Match>
+ <Match when={hasPermissions()}>
+ <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
+ <Match when={hasError()}>
+ <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={unseenCount() > 0}>
+ <div class="size-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ </Switch>
+ </div>
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {props.session.title}
+ </span>
+ <Show when={props.session.summary}>
+ {(summary) => (
+ <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+ <DiffChanges changes={summary()} />
+ </div>
+ )}
+ </Show>
+ </div>
+ </A>
+ )
+
+ return (
+ <div
+ data-session-id={props.session.id}
+ class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
+ hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
+ >
+ <Show
+ when={hoverEnabled()}
+ fallback={
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+ {item}
+ </Tooltip>
+ }
+ >
+ <HoverCard
+ openDelay={1000}
+ closeDelay={props.sidebarHovering() ? 600 : 0}
+ placement="right-start"
+ gutter={16}
+ shift={-2}
+ trigger={item}
+ mount={!props.mobile ? props.nav() : undefined}
+ open={props.hoverSession() === props.session.id}
+ onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
+ >
+ <Show
+ when={hoverReady()}
+ fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
+ >
+ <div class="overflow-y-auto max-h-72 h-full">
+ <MessageNav
+ messages={hoverMessages() ?? []}
+ current={undefined}
+ getLabel={messageLabel}
+ onMessageSelect={(message) => {
+ if (!isActive()) {
+ layout.pendingMessage.set(
+ `${base64Encode(props.session.directory)}/${props.session.id}`,
+ message.id,
+ )
+ navigate(`${props.slug}/session/${props.session.id}`)
+ return
+ }
+ window.history.replaceState(null, "", `#message-${message.id}`)
+ window.dispatchEvent(new HashChangeEvent("hashchange"))
+ }}
+ size="normal"
+ class="w-60"
+ />
+ </div>
+ </Show>
+ </HoverCard>
+ </Show>
+ <div
+ class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
+ classList={{
+ "opacity-100 pointer-events-auto": !!props.mobile,
+ "opacity-0 pointer-events-none": !props.mobile,
+ "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
+ "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
+ }}
+ >
+ <Tooltip value={language.t("common.archive")} placement="top">
+ <IconButton
+ icon="archive"
+ variant="ghost"
+ class="size-6 rounded-md"
+ aria-label={language.t("common.archive")}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ void props.archiveSession(props.session)
+ }}
+ />
+ </Tooltip>
+ </div>
+ </div>
+ )
+}
+
+export const NewSessionItem = (props: {
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ sidebarExpanded: Accessor<boolean>
+ clearHoverProjectSoon: () => void
+ setHoverSession: (id: string | undefined) => void
+}): JSX.Element => {
+ const layout = useLayout()
+ const language = useLanguage()
+ const label = language.t("command.session.new")
+ const tooltip = () => props.mobile || !props.sidebarExpanded()
+ const item = (
+ <A
+ href={`${props.slug}/session`}
+ end
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
+ onClick={() => {
+ props.setHoverSession(undefined)
+ if (layout.sidebar.opened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div class="shrink-0 size-6 flex items-center justify-center">
+ <Icon name="plus-small" size="small" class="text-icon-weak" />
+ </div>
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {label}
+ </span>
+ </div>
+ </A>
+ )
+
+ return (
+ <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
+ <Show
+ when={!tooltip()}
+ fallback={
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
+ {item}
+ </Tooltip>
+ }
+ >
+ {item}
+ </Show>
+ </div>
+ )
+}
+
+export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
+ const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
+ return (
+ <div class="flex flex-col gap-1">
+ <For each={items}>
+ {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
+ </For>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts
new file mode 100644
index 000000000..75958d49e
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+describe("projectSelected", () => {
+ test("matches direct worktree", () => {
+ expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
+ })
+
+ test("matches sandbox worktree", () => {
+ expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
+ expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
+ })
+})
+
+describe("projectTileActive", () => {
+ test("menu state always wins", () => {
+ expect(
+ projectTileActive({
+ menu: true,
+ preview: false,
+ open: false,
+ overlay: false,
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ })
+
+ test("preview mode uses open state", () => {
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: true,
+ open: true,
+ overlay: true,
+ hoverProject: "/tmp/other",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ })
+
+ test("overlay mode uses hovered project", () => {
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: false,
+ open: false,
+ overlay: true,
+ hoverProject: "/tmp/root",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: false,
+ open: false,
+ overlay: true,
+ hoverProject: "/tmp/other",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts
new file mode 100644
index 000000000..06d38a3cd
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project-helpers.ts
@@ -0,0 +1,11 @@
+export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
+ worktree === currentDir || sandboxes?.includes(currentDir) === true
+
+export const projectTileActive = (args: {
+ menu: boolean
+ preview: boolean
+ open: boolean
+ overlay: boolean
+ hoverProject?: string
+ worktree: string
+}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
new file mode 100644
index 000000000..c91dc987d
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -0,0 +1,283 @@
+import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Button } from "@opencode-ai/ui/button"
+import { ContextMenu } from "@opencode-ai/ui/context-menu"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
+import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+export type ProjectSidebarContext = {
+ currentDir: Accessor<string>
+ sidebarOpened: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ hoverProject: Accessor<string | undefined>
+ nav: Accessor<HTMLElement | undefined>
+ onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
+ onProjectMouseLeave: (worktree: string) => void
+ onProjectFocus: (worktree: string) => void
+ navigateToProject: (directory: string) => void
+ openSidebar: () => void
+ closeProject: (directory: string) => void
+ showEditProjectDialog: (project: LocalProject) => void
+ toggleProjectWorkspaces: (project: LocalProject) => void
+ workspacesEnabled: (project: LocalProject) => boolean
+ workspaceIds: (project: LocalProject) => string[]
+ workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+ sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
+ setHoverSession: (id: string | undefined) => void
+}
+
+export const ProjectDragOverlay = (props: {
+ projects: Accessor<LocalProject[]>
+ activeProject: Accessor<string | undefined>
+}): JSX.Element => {
+ const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
+ return (
+ <Show when={project()}>
+ {(p) => (
+ <div class="bg-background-base rounded-xl p-1">
+ <ProjectIcon project={p()} />
+ </div>
+ )}
+ </Show>
+ )
+}
+
+export const SortableProject = (props: {
+ project: LocalProject
+ mobile?: boolean
+ ctx: ProjectSidebarContext
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const sortable = createSortable(props.project.worktree)
+ const selected = createMemo(() =>
+ projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
+ )
+ const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
+ const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
+ const [open, setOpen] = createSignal(false)
+ const [menu, setMenu] = createSignal(false)
+
+ const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
+ const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
+ const active = createMemo(() =>
+ projectTileActive({
+ menu: menu(),
+ preview: preview(),
+ open: open(),
+ overlay: overlay(),
+ hoverProject: props.ctx.hoverProject(),
+ worktree: props.project.worktree,
+ }),
+ )
+
+ createEffect(() => {
+ if (preview()) return
+ if (!open()) return
+ setOpen(false)
+ })
+
+ const label = (directory: string) => {
+ const [data] = globalSync.child(directory, { bootstrap: false })
+ const kind =
+ directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+ const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
+ return `${kind} : ${name}`
+ }
+
+ const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
+ const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
+ const projectChildren = createMemo(() => childMapByParent(projectStore().session))
+ const workspaceSessions = (directory: string) => {
+ const [data] = globalSync.child(directory, { bootstrap: false })
+ return sortedRootSessions(data, Date.now()).slice(0, 2)
+ }
+ const workspaceChildren = (directory: string) => {
+ const [data] = globalSync.child(directory, { bootstrap: false })
+ return childMapByParent(data.session)
+ }
+
+ const Trigger = () => (
+ <ContextMenu
+ modal={!props.ctx.sidebarHovering()}
+ onOpenChange={(value) => {
+ setMenu(value)
+ if (value) setOpen(false)
+ }}
+ >
+ <ContextMenu.Trigger
+ as="button"
+ type="button"
+ aria-label={displayName(props.project)}
+ data-action="project-switch"
+ data-project={base64Encode(props.project.worktree)}
+ classList={{
+ "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+ "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
+ "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+ !selected() && !active(),
+ "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+ }}
+ onMouseEnter={(event: MouseEvent) => {
+ if (!overlay()) return
+ props.ctx.onProjectMouseEnter(props.project.worktree, event)
+ }}
+ onMouseLeave={() => {
+ if (!overlay()) return
+ props.ctx.onProjectMouseLeave(props.project.worktree)
+ }}
+ onFocus={() => {
+ if (!overlay()) return
+ props.ctx.onProjectFocus(props.project.worktree)
+ }}
+ onClick={() => props.ctx.navigateToProject(props.project.worktree)}
+ onBlur={() => setOpen(false)}
+ >
+ <ProjectIcon project={props.project} notify />
+ </ContextMenu.Trigger>
+ <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+ <ContextMenu.Content>
+ <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
+ <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={base64Encode(props.project.worktree)}
+ disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
+ onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
+ >
+ <ContextMenu.ItemLabel>
+ {props.ctx.workspacesEnabled(props.project)
+ ? language.t("sidebar.workspaces.disable")
+ : language.t("sidebar.workspaces.enable")}
+ </ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Separator />
+ <ContextMenu.Item
+ data-action="project-close-menu"
+ data-project={base64Encode(props.project.worktree)}
+ onSelect={() => props.ctx.closeProject(props.project.worktree)}
+ >
+ <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ </ContextMenu.Content>
+ </ContextMenu.Portal>
+ </ContextMenu>
+ )
+
+ return (
+ // @ts-ignore
+ <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
+ <Show when={preview()} fallback={<Trigger />}>
+ <HoverCard
+ open={open() && !menu()}
+ openDelay={0}
+ closeDelay={0}
+ placement="right-start"
+ gutter={6}
+ trigger={<Trigger />}
+ onOpenChange={(value) => {
+ if (menu()) return
+ setOpen(value)
+ if (value) props.ctx.setHoverSession(undefined)
+ }}
+ >
+ <div class="-m-3 p-2 flex flex-col w-72">
+ <div class="px-4 pt-2 pb-1 flex items-center gap-2">
+ <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
+ <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
+ <IconButton
+ icon="circle-x"
+ variant="ghost"
+ class="shrink-0"
+ data-action="project-close-hover"
+ data-project={base64Encode(props.project.worktree)}
+ aria-label={language.t("common.close")}
+ onClick={(event) => {
+ event.stopPropagation()
+ setOpen(false)
+ props.ctx.closeProject(props.project.worktree)
+ }}
+ />
+ </Tooltip>
+ </div>
+ <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
+ <div class="px-2 pb-2 flex flex-col gap-2">
+ <Show
+ when={workspaceEnabled()}
+ fallback={
+ <For each={projectSessions()}>
+ {(session) => (
+ <SessionItem
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(props.project.worktree)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={projectChildren()}
+ />
+ )}
+ </For>
+ }
+ >
+ <For each={workspaces()}>
+ {(directory) => {
+ const sessions = createMemo(() => workspaceSessions(directory))
+ const children = createMemo(() => workspaceChildren(directory))
+ return (
+ <div class="flex flex-col gap-1">
+ <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+ <div class="shrink-0 size-6 flex items-center justify-center">
+ <Icon name="branch" size="small" class="text-icon-base" />
+ </div>
+ <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
+ </div>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(directory)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={children()}
+ />
+ )}
+ </For>
+ </div>
+ )
+ }}
+ </For>
+ </Show>
+ </div>
+ <div class="px-2 py-2 border-t border-border-weak-base">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
+ onClick={() => {
+ props.ctx.openSidebar()
+ setOpen(false)
+ if (selected()) return
+ props.ctx.navigateToProject(props.project.worktree)
+ }}
+ >
+ {language.t("sidebar.project.viewAllSessions")}
+ </Button>
+ </div>
+ </div>
+ </HoverCard>
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts
new file mode 100644
index 000000000..93c286c15
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell-helpers.ts
@@ -0,0 +1 @@
+export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts
new file mode 100644
index 000000000..694025a65
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+describe("sidebarExpanded", () => {
+ test("expands on mobile regardless of desktop open state", () => {
+ expect(sidebarExpanded(true, false)).toBe(true)
+ })
+
+ test("follows desktop open state when not mobile", () => {
+ expect(sidebarExpanded(false, true)).toBe(true)
+ expect(sidebarExpanded(false, false)).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
new file mode 100644
index 000000000..ce96a09d1
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -0,0 +1,109 @@
+import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ type DragEvent,
+} from "@thisbeyond/solid-dnd"
+import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { type LocalProject } from "@/context/layout"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+export const SidebarContent = (props: {
+ mobile?: boolean
+ opened: Accessor<boolean>
+ aimMove: (event: MouseEvent) => void
+ projects: Accessor<LocalProject[]>
+ renderProject: (project: LocalProject) => JSX.Element
+ handleDragStart: (event: unknown) => void
+ handleDragEnd: () => void
+ handleDragOver: (event: DragEvent) => void
+ openProjectLabel: JSX.Element
+ openProjectKeybind: Accessor<string | undefined>
+ onOpenProject: () => void
+ renderProjectOverlay: () => JSX.Element
+ settingsLabel: Accessor<string>
+ settingsKeybind: Accessor<string | undefined>
+ onOpenSettings: () => void
+ helpLabel: Accessor<string>
+ onOpenHelp: () => void
+ renderPanel: () => JSX.Element
+}): JSX.Element => {
+ const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+
+ return (
+ <div class="flex h-full w-full overflow-hidden">
+ <div
+ class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
+ onMouseMove={props.aimMove}
+ >
+ <div class="flex-1 min-h-0 w-full">
+ <DragDropProvider
+ onDragStart={props.handleDragStart}
+ onDragEnd={props.handleDragEnd}
+ onDragOver={props.handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
+ <SortableProvider ids={props.projects().map((p) => p.worktree)}>
+ <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
+ </SortableProvider>
+ <Tooltip
+ placement={props.mobile ? "bottom" : "right"}
+ value={
+ <div class="flex items-center gap-2">
+ <span>{props.openProjectLabel}</span>
+ <Show when={!props.mobile && !!props.openProjectKeybind()}>
+ <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
+ </Show>
+ </div>
+ }
+ >
+ <IconButton
+ icon="plus"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenProject}
+ aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
+ />
+ </Tooltip>
+ </div>
+ <DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
+ </DragDropProvider>
+ </div>
+ <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
+ <TooltipKeybind
+ placement={props.mobile ? "bottom" : "right"}
+ title={props.settingsLabel()}
+ keybind={props.settingsKeybind() ?? ""}
+ >
+ <IconButton
+ icon="settings-gear"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenSettings}
+ aria-label={props.settingsLabel()}
+ />
+ </TooltipKeybind>
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
+ <IconButton
+ icon="help"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenHelp}
+ aria-label={props.helpLabel()}
+ />
+ </Tooltip>
+ </div>
+ </div>
+
+ <Show when={expanded()}>{props.renderPanel()}</Show>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts
new file mode 100644
index 000000000..aa7cb480e
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts
@@ -0,0 +1,2 @@
+export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
+ expanded[directory] ?? local
diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts
new file mode 100644
index 000000000..d71c39fc8
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { workspaceOpenState } from "./sidebar-workspace-helpers"
+
+describe("workspaceOpenState", () => {
+ test("defaults to local workspace open", () => {
+ expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
+ })
+
+ test("uses persisted expansion state when present", () => {
+ expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
+ expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
new file mode 100644
index 000000000..11bad84b0
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -0,0 +1,387 @@
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { Button } from "@opencode-ai/ui/button"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
+import { childMapByParent, sortedRootSessions } from "./helpers"
+
+type InlineEditorComponent = (props: {
+ id: string
+ value: Accessor<string>
+ onSave: (next: string) => void
+ class?: string
+ displayClass?: string
+ editing?: boolean
+ stopPropagation?: boolean
+ openOnDblClick?: boolean
+}) => JSX.Element
+
+export type WorkspaceSidebarContext = {
+ currentDir: Accessor<string>
+ sidebarExpanded: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ nav: Accessor<HTMLElement | undefined>
+ hoverSession: Accessor<string | undefined>
+ setHoverSession: (id: string | undefined) => void
+ clearHoverProjectSoon: () => void
+ prefetchSession: (session: Session, priority?: "high" | "low") => void
+ archiveSession: (session: Session) => Promise<void>
+ workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
+ renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
+ editorOpen: (id: string) => boolean
+ openEditor: (id: string, value: string) => void
+ closeEditor: () => void
+ setEditor: (key: "value", value: string) => void
+ InlineEditor: InlineEditorComponent
+ isBusy: (directory: string) => boolean
+ workspaceExpanded: (directory: string, local: boolean) => boolean
+ setWorkspaceExpanded: (directory: string, value: boolean) => void
+ showResetWorkspaceDialog: (root: string, directory: string) => void
+ showDeleteWorkspaceDialog: (root: string, directory: string) => void
+ setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
+}
+
+export const WorkspaceDragOverlay = (props: {
+ sidebarProject: Accessor<LocalProject | undefined>
+ activeWorkspace: Accessor<string | undefined>
+ workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const label = createMemo(() => {
+ const project = props.sidebarProject()
+ if (!project) return
+ const directory = props.activeWorkspace()
+ if (!directory) return
+
+ const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
+ const kind =
+ directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+ const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
+ return `${kind} : ${name}`
+ })
+
+ return (
+ <Show when={label()}>
+ {(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
+ </Show>
+ )
+}
+
+export const SortableWorkspace = (props: {
+ ctx: WorkspaceSidebarContext
+ directory: string
+ project: LocalProject
+ mobile?: boolean
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const sortable = createSortable(props.directory)
+ const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
+ const [menu, setMenu] = createStore({
+ open: false,
+ pendingRename: false,
+ })
+ const slug = createMemo(() => base64Encode(props.directory))
+ const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
+ const children = createMemo(() => childMapByParent(workspaceStore.session))
+ const local = createMemo(() => props.directory === props.project.worktree)
+ const active = createMemo(() => props.ctx.currentDir() === props.directory)
+ const workspaceValue = createMemo(() => {
+ const branch = workspaceStore.vcs?.branch
+ const name = branch ?? getFilename(props.directory)
+ return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
+ })
+ const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
+ const boot = createMemo(() => open() || active())
+ const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
+ const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
+ const busy = createMemo(() => props.ctx.isBusy(props.directory))
+ const wasBusy = createMemo((prev) => prev || busy(), false)
+ const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
+ const loadMore = async () => {
+ setWorkspaceStore("limit", (limit) => limit + 5)
+ await globalSync.project.loadSessions(props.directory)
+ }
+
+ const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
+
+ const openWrapper = (value: boolean) => {
+ props.ctx.setWorkspaceExpanded(props.directory, value)
+ if (value) return
+ if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
+ }
+
+ createEffect(() => {
+ if (!boot()) return
+ globalSync.child(props.directory, { bootstrap: true })
+ })
+
+ const header = () => (
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <div class="flex items-center justify-center shrink-0 size-6">
+ <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
+ <Spinner class="size-[15px]" />
+ </Show>
+ </div>
+ <span class="text-14-medium text-text-base shrink-0">
+ {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
+ </span>
+ <Show
+ when={!local()}
+ fallback={
+ <span class="text-14-medium text-text-base min-w-0 truncate">
+ {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
+ </span>
+ }
+ >
+ <props.ctx.InlineEditor
+ id={`workspace:${props.directory}`}
+ value={workspaceValue}
+ onSave={(next) => {
+ const trimmed = next.trim()
+ if (!trimmed) return
+ props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
+ props.ctx.setEditor("value", workspaceValue())
+ }}
+ class="text-14-medium text-text-base min-w-0 truncate"
+ displayClass="text-14-medium text-text-base min-w-0 truncate"
+ editing={workspaceEditActive()}
+ stopPropagation={false}
+ openOnDblClick={false}
+ />
+ </Show>
+ <Icon
+ name={open() ? "chevron-down" : "chevron-right"}
+ size="small"
+ class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
+ />
+ </div>
+ )
+
+ return (
+ <div
+ // @ts-ignore
+ use:sortable
+ classList={{
+ "opacity-30": sortable.isActiveDraggable,
+ "opacity-50 pointer-events-none": busy(),
+ }}
+ >
+ <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
+ <div class="px-2 py-1">
+ <div
+ class="group/workspace relative"
+ data-component="workspace-item"
+ data-workspace={base64Encode(props.directory)}
+ >
+ <div class="flex items-center gap-1">
+ <Show
+ when={workspaceEditActive()}
+ fallback={
+ <Collapsible.Trigger
+ class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
+ data-action="workspace-toggle"
+ data-workspace={base64Encode(props.directory)}
+ >
+ {header()}
+ </Collapsible.Trigger>
+ }
+ >
+ <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
+ </Show>
+ <div
+ class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+ classList={{
+ "opacity-100 pointer-events-auto": menu.open,
+ "opacity-0 pointer-events-none": !menu.open,
+ "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+ "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+ }}
+ >
+ <DropdownMenu
+ modal={!props.ctx.sidebarHovering()}
+ open={menu.open}
+ onOpenChange={(open) => setMenu("open", open)}
+ >
+ <Tooltip value={language.t("common.moreOptions")} placement="top">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md"
+ data-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
+ aria-label={language.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!menu.pendingRename) return
+ event.preventDefault()
+ setMenu("pendingRename", false)
+ props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
+ }}
+ >
+ <DropdownMenu.Item
+ disabled={local()}
+ onSelect={() => {
+ setMenu("pendingRename", true)
+ setMenu("open", false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={local() || busy()}
+ onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={local() || busy()}
+ onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Collapsible.Content>
+ <nav class="flex flex-col gap-1 px-2">
+ <NewSessionItem
+ slug={slug()}
+ mobile={props.mobile}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ setHoverSession={props.ctx.setHoverSession}
+ />
+ <Show when={loading()}>
+ <SessionSkeleton />
+ </Show>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={slug()}
+ mobile={props.mobile}
+ children={children()}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ sidebarHovering={props.ctx.sidebarHovering}
+ nav={props.ctx.nav}
+ hoverSession={props.ctx.hoverSession}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ prefetchSession={props.ctx.prefetchSession}
+ archiveSession={props.ctx.archiveSession}
+ />
+ )}
+ </For>
+ <Show when={hasMore()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+ size="large"
+ onClick={(e: MouseEvent) => {
+ loadMore()
+ ;(e.currentTarget as HTMLButtonElement).blur()
+ }}
+ >
+ {language.t("common.loadMore")}
+ </Button>
+ </div>
+ </Show>
+ </nav>
+ </Collapsible.Content>
+ </Collapsible>
+ </div>
+ )
+}
+
+export const LocalWorkspace = (props: {
+ ctx: WorkspaceSidebarContext
+ project: LocalProject
+ mobile?: boolean
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const workspace = createMemo(() => {
+ const [store, setStore] = globalSync.child(props.project.worktree)
+ return { store, setStore }
+ })
+ const slug = createMemo(() => base64Encode(props.project.worktree))
+ const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
+ const children = createMemo(() => childMapByParent(workspace().store.session))
+ const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
+ const loading = createMemo(() => !booted() && sessions().length === 0)
+ const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
+ const loadMore = async () => {
+ workspace().setStore("limit", (limit) => limit + 5)
+ await globalSync.project.loadSessions(props.project.worktree)
+ }
+
+ return (
+ <div
+ ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
+ class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+ >
+ <nav class="flex flex-col gap-1 px-2">
+ <Show when={loading()}>
+ <SessionSkeleton />
+ </Show>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={slug()}
+ mobile={props.mobile}
+ children={children()}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ sidebarHovering={props.ctx.sidebarHovering}
+ nav={props.ctx.nav}
+ hoverSession={props.ctx.hoverSession}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ prefetchSession={props.ctx.prefetchSession}
+ archiveSession={props.ctx.archiveSession}
+ />
+ )}
+ </For>
+ <Show when={hasMore()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+ size="large"
+ onClick={(e: MouseEvent) => {
+ loadMore()
+ ;(e.currentTarget as HTMLButtonElement).blur()
+ }}
+ >
+ {language.t("common.loadMore")}
+ </Button>
+ </div>
+ </Show>
+ </nav>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 67606e860..a70d4e8a2 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,84 +1,63 @@
-import {
- For,
- onCleanup,
- onMount,
- Show,
- Match,
- Switch,
- createMemo,
- createEffect,
- createSignal,
- on,
- type JSX,
-} from "solid-js"
+import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
-import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
-import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
-import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
-import { SessionReview } from "@opencode-ai/ui/session-review"
import { Mark } from "@opencode-ai/ui/logo"
-import { QuestionDock } from "@/components/question-dock"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
-import { Terminal } from "@/components/terminal"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
-import { DialogSelectModel } from "@/components/dialog-select-model"
-import { DialogSelectMcp } from "@/components/dialog-select-mcp"
-import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
-import type { FileDiff } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
-import { useComments, type LineComment } from "@/context/comments"
-import { extractPromptFromParts } from "@/utils/prompt"
+import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
-import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
-import {
- SessionHeader,
- SessionContextTab,
- SortableTab,
- FileVisual,
- SortableTerminalTab,
- NewSessionView,
-} from "@/components/session"
+import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
-import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
+import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
-
-type DiffStyle = "unified" | "split"
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import {
+ SessionReviewTab,
+ StickyAddButton,
+ type DiffStyle,
+ type SessionReviewTabProps,
+} from "@/pages/session/review-tab"
+import { TerminalPanel } from "@/pages/session/terminal-panel"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+import { MessageTimeline } from "@/pages/session/message-timeline"
+import { useSessionCommands } from "@/pages/session/use-session-commands"
+import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
+import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
+import { SessionSidePanel } from "@/pages/session/session-side-panel"
+import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
@@ -107,155 +86,6 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
touch(handoff.session, key, { ...prev, ...patch })
}
-interface SessionReviewTabProps {
- title?: JSX.Element
- empty?: JSX.Element
- diffs: () => FileDiff[]
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- diffStyle: DiffStyle
- onDiffStyleChange?: (style: DiffStyle) => void
- onViewFile?: (file: string) => void
- onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
- comments?: LineComment[]
- focusedComment?: { file: string; id: string } | null
- onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
- focusedFile?: string
- onScrollRef?: (el: HTMLDivElement) => void
- classes?: {
- root?: string
- header?: string
- container?: string
- }
-}
-
-function StickyAddButton(props: { children: JSX.Element }) {
- const [stuck, setStuck] = createSignal(false)
- let button: HTMLDivElement | undefined
-
- createEffect(() => {
- const node = button
- if (!node) return
-
- const scroll = node.parentElement
- if (!scroll) return
-
- const handler = () => {
- const rect = node.getBoundingClientRect()
- const scrollRect = scroll.getBoundingClientRect()
- setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
- }
-
- scroll.addEventListener("scroll", handler, { passive: true })
- const observer = new ResizeObserver(handler)
- observer.observe(scroll)
- handler()
- onCleanup(() => {
- scroll.removeEventListener("scroll", handler)
- observer.disconnect()
- })
- })
-
- return (
- <div
- ref={button}
- class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
- classList={{ "border-l": stuck() }}
- >
- {props.children}
- </div>
- )
-}
-
-function SessionReviewTab(props: SessionReviewTabProps) {
- let scroll: HTMLDivElement | undefined
- let frame: number | undefined
- let pending: { x: number; y: number } | undefined
-
- const sdk = useSDK()
-
- const readFile = async (path: string) => {
- return sdk.client.file
- .read({ path })
- .then((x) => x.data)
- .catch(() => undefined)
- }
-
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
-
- const s = props.view().scroll("review")
- if (!s) return
-
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
-
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (frame !== undefined) return
-
- frame = requestAnimationFrame(() => {
- frame = undefined
-
- const next = pending
- pending = undefined
- if (!next) return
-
- props.view().setScroll("review", next)
- })
- }
-
- createEffect(
- on(
- () => props.diffs().length,
- () => {
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- onCleanup(() => {
- if (frame === undefined) return
- cancelAnimationFrame(frame)
- })
-
- return (
- <SessionReview
- title={props.title}
- empty={props.empty}
- scrollRef={(el) => {
- scroll = el
- props.onScrollRef?.(el)
- restoreScroll()
- }}
- onScroll={handleScroll}
- onDiffRendered={() => requestAnimationFrame(restoreScroll)}
- open={props.view().review.open()}
- onOpenChange={props.view().review.setOpen}
- classes={{
- root: props.classes?.root ?? "pb-40",
- header: props.classes?.header ?? "px-6",
- container: props.classes?.container ?? "px-6",
- }}
- diffs={props.diffs()}
- diffStyle={props.diffStyle}
- onDiffStyleChange={props.onDiffStyleChange}
- onViewFile={props.onViewFile}
- focusedFile={props.focusedFile}
- readFile={readFile}
- onLineComment={props.onLineComment}
- comments={props.comments}
- focusedComment={props.focusedComment}
- onFocusedCommentChange={props.onFocusedCommentChange}
- />
- )
-}
-
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -820,8 +650,6 @@ export default function Page() {
const scrollGestureWindowMs = 250
- let touchGesture: number | undefined
-
const markScrollGesture = (target?: EventTarget | null) => {
const root = scroller
if (!root) return
@@ -963,396 +791,6 @@ export default function Page() {
})
}
- const sessionCommands = createMemo(() => [
- {
- id: "session.new",
- title: language.t("command.session.new"),
- category: language.t("command.category.session"),
- keybind: "mod+shift+s",
- slash: "new",
- onSelect: () => navigate(`/${params.dir}/session`),
- },
- ])
-
- const fileCommands = createMemo(() => [
- {
- id: "file.open",
- title: language.t("command.file.open"),
- description: language.t("palette.search.placeholder"),
- category: language.t("command.category.file"),
- keybind: "mod+p",
- slash: "open",
- onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
- },
- {
- id: "tab.close",
- title: language.t("command.tab.close"),
- category: language.t("command.category.file"),
- keybind: "mod+w",
- disabled: !tabs().active(),
- onSelect: () => {
- const active = tabs().active()
- if (!active) return
- tabs().close(active)
- },
- },
- ])
-
- const contextCommands = createMemo(() => [
- {
- id: "context.addSelection",
- title: language.t("command.context.addSelection"),
- description: language.t("command.context.addSelection.description"),
- category: language.t("command.category.context"),
- keybind: "mod+shift+l",
- disabled: (() => {
- const active = tabs().active()
- if (!active) return true
- const path = file.pathFromTab(active)
- if (!path) return true
- return file.selectedLines(path) == null
- })(),
- onSelect: () => {
- const active = tabs().active()
- if (!active) return
- const path = file.pathFromTab(active)
- if (!path) return
-
- const range = file.selectedLines(path)
- if (!range) {
- showToast({
- title: language.t("toast.context.noLineSelection.title"),
- description: language.t("toast.context.noLineSelection.description"),
- })
- return
- }
-
- addSelectionToContext(path, selectionFromLines(range))
- },
- },
- ])
-
- const viewCommands = createMemo(() => [
- {
- id: "terminal.toggle",
- title: language.t("command.terminal.toggle"),
- description: "",
- category: language.t("command.category.view"),
- keybind: "ctrl+`",
- slash: "terminal",
- onSelect: () => view().terminal.toggle(),
- },
- {
- id: "review.toggle",
- title: language.t("command.review.toggle"),
- description: "",
- category: language.t("command.category.view"),
- keybind: "mod+shift+r",
- onSelect: () => view().reviewPanel.toggle(),
- },
- {
- id: "fileTree.toggle",
- title: language.t("command.fileTree.toggle"),
- description: "",
- category: language.t("command.category.view"),
- onSelect: () => {
- const opening = !layout.fileTree.opened()
- if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
- layout.fileTree.toggle()
- },
- },
- {
- id: "terminal.new",
- title: language.t("command.terminal.new"),
- description: language.t("command.terminal.new.description"),
- category: language.t("command.category.terminal"),
- keybind: "ctrl+alt+t",
- onSelect: () => {
- if (terminal.all().length > 0) terminal.new()
- view().terminal.open()
- },
- },
- {
- id: "steps.toggle",
- title: language.t("command.steps.toggle"),
- description: language.t("command.steps.toggle.description"),
- category: language.t("command.category.view"),
- keybind: "mod+e",
- slash: "steps",
- disabled: !params.id,
- onSelect: () => {
- const msg = activeMessage()
- if (!msg) return
- setStore("expanded", msg.id, (open: boolean | undefined) => !open)
- },
- },
- ])
-
- const messageCommands = createMemo(() => [
- {
- id: "message.previous",
- title: language.t("command.message.previous"),
- description: language.t("command.message.previous.description"),
- category: language.t("command.category.session"),
- keybind: "mod+arrowup",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(-1),
- },
- {
- id: "message.next",
- title: language.t("command.message.next"),
- description: language.t("command.message.next.description"),
- category: language.t("command.category.session"),
- keybind: "mod+arrowdown",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(1),
- },
- ])
-
- const agentCommands = createMemo(() => [
- {
- id: "model.choose",
- title: language.t("command.model.choose"),
- description: language.t("command.model.choose.description"),
- category: language.t("command.category.model"),
- keybind: "mod+'",
- slash: "model",
- onSelect: () => dialog.show(() => <DialogSelectModel />),
- },
- {
- id: "mcp.toggle",
- title: language.t("command.mcp.toggle"),
- description: language.t("command.mcp.toggle.description"),
- category: language.t("command.category.mcp"),
- keybind: "mod+;",
- slash: "mcp",
- onSelect: () => dialog.show(() => <DialogSelectMcp />),
- },
- {
- id: "agent.cycle",
- title: language.t("command.agent.cycle"),
- description: language.t("command.agent.cycle.description"),
- category: language.t("command.category.agent"),
- keybind: "mod+.",
- slash: "agent",
- onSelect: () => local.agent.move(1),
- },
- {
- id: "agent.cycle.reverse",
- title: language.t("command.agent.cycle.reverse"),
- description: language.t("command.agent.cycle.reverse.description"),
- category: language.t("command.category.agent"),
- keybind: "shift+mod+.",
- onSelect: () => local.agent.move(-1),
- },
- {
- id: "model.variant.cycle",
- title: language.t("command.model.variant.cycle"),
- description: language.t("command.model.variant.cycle.description"),
- category: language.t("command.category.model"),
- keybind: "shift+mod+d",
- onSelect: () => {
- local.model.variant.cycle()
- },
- },
- ])
-
- const permissionCommands = createMemo(() => [
- {
- id: "permissions.autoaccept",
- title:
- params.id && permission.isAutoAccepting(params.id, sdk.directory)
- ? language.t("command.permissions.autoaccept.disable")
- : language.t("command.permissions.autoaccept.enable"),
- category: language.t("command.category.permissions"),
- keybind: "mod+shift+a",
- disabled: !params.id || !permission.permissionsEnabled(),
- onSelect: () => {
- const sessionID = params.id
- if (!sessionID) return
- permission.toggleAutoAccept(sessionID, sdk.directory)
- showToast({
- title: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.title")
- : language.t("toast.permissions.autoaccept.off.title"),
- description: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.description")
- : language.t("toast.permissions.autoaccept.off.description"),
- })
- },
- },
- ])
-
- const sessionActionCommands = createMemo(() => [
- {
- id: "session.undo",
- title: language.t("command.session.undo"),
- description: language.t("command.session.undo.description"),
- category: language.t("command.category.session"),
- slash: "undo",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- if (status()?.type !== "idle") {
- await sdk.client.session.abort({ sessionID }).catch(() => {})
- }
- const revert = info()?.revert?.messageID
- const message = findLast(userMessages(), (x) => !revert || x.id < revert)
- if (!message) return
- await sdk.client.session.revert({ sessionID, messageID: message.id })
- const parts = sync.data.part[message.id]
- if (parts) {
- const restored = extractPromptFromParts(parts, { directory: sdk.directory })
- prompt.set(restored)
- }
- const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
- setActiveMessage(priorMessage)
- },
- },
- {
- id: "session.redo",
- title: language.t("command.session.redo"),
- description: language.t("command.session.redo.description"),
- category: language.t("command.category.session"),
- slash: "redo",
- disabled: !params.id || !info()?.revert?.messageID,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- const revertMessageID = info()?.revert?.messageID
- if (!revertMessageID) return
- const nextMessage = userMessages().find((x) => x.id > revertMessageID)
- if (!nextMessage) {
- await sdk.client.session.unrevert({ sessionID })
- prompt.reset()
- const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
- setActiveMessage(lastMsg)
- return
- }
- await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
- setActiveMessage(priorMsg)
- },
- },
- {
- id: "session.compact",
- title: language.t("command.session.compact"),
- description: language.t("command.session.compact.description"),
- category: language.t("command.category.session"),
- slash: "compact",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- const model = local.model.current()
- if (!model) {
- showToast({
- title: language.t("toast.model.none.title"),
- description: language.t("toast.model.none.description"),
- })
- return
- }
- await sdk.client.session.summarize({
- sessionID,
- modelID: model.id,
- providerID: model.provider.id,
- })
- },
- },
- {
- id: "session.fork",
- title: language.t("command.session.fork"),
- description: language.t("command.session.fork.description"),
- category: language.t("command.category.session"),
- slash: "fork",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: () => dialog.show(() => <DialogFork />),
- },
- ])
-
- const shareCommands = createMemo(() => {
- if (sync.data.config.share === "disabled") return []
- return [
- {
- id: "session.share",
- title: language.t("command.session.share"),
- description: language.t("command.session.share.description"),
- category: language.t("command.category.session"),
- slash: "share",
- disabled: !params.id || !!info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .share({ sessionID: params.id })
- .then((res) => {
- navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
- showToast({
- title: language.t("toast.session.share.copyFailed.title"),
- variant: "error",
- }),
- )
- })
- .then(() =>
- showToast({
- title: language.t("toast.session.share.success.title"),
- description: language.t("toast.session.share.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.share.failed.title"),
- description: language.t("toast.session.share.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- {
- id: "session.unshare",
- title: language.t("command.session.unshare"),
- description: language.t("command.session.unshare.description"),
- category: language.t("command.category.session"),
- slash: "unshare",
- disabled: !params.id || !info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .unshare({ sessionID: params.id })
- .then(() =>
- showToast({
- title: language.t("toast.session.unshare.success.title"),
- description: language.t("toast.session.unshare.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.unshare.failed.title"),
- description: language.t("toast.session.unshare.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- ]
- })
-
- command.register("session", () =>
- combineCommandSections([
- sessionCommands(),
- fileCommands(),
- contextCommands(),
- viewCommands(),
- messageCommands(),
- agentCommands(),
- permissionCommands(),
- sessionActionCommands(),
- shareCommands(),
- ]),
- )
-
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
@@ -1465,6 +903,34 @@ export default function Page() {
setFileTreeTab("all")
}
+ useSessionCommands({
+ command,
+ dialog,
+ file,
+ language,
+ local,
+ permission,
+ prompt,
+ sdk,
+ sync,
+ terminal,
+ layout,
+ params,
+ navigate,
+ tabs,
+ view,
+ info,
+ status,
+ userMessages,
+ visibleUserMessages,
+ activeMessage,
+ showAllFiles,
+ navigateMessageByOffset,
+ setExpanded: (id, fn) => setStore("expanded", id, fn),
+ setActiveMessage,
+ addSelectionToContext,
+ })
+
const openReviewFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
@@ -1781,11 +1247,6 @@ export default function Page() {
overflowAnchor: "dynamic",
})
- const clearMessageHash = () => {
- if (!window.location.hash) return
- window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
- }
-
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
const scrollSpy = createScrollSpy({
@@ -1975,162 +1436,23 @@ export default function Page() {
},
)
- const updateHash = (id: string) => {
- window.history.replaceState(null, "", `#${anchor(id)}`)
- }
-
- createEffect(
- on(sessionKey, (key) => {
- if (!params.id) return
- const messageID = layout.pendingMessage.consume(key)
- if (!messageID) return
- setUi("pendingMessage", messageID)
- }),
- )
-
- const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
- const root = scroller
- if (!root) return false
-
- const a = el.getBoundingClientRect()
- const b = root.getBoundingClientRect()
- const top = a.top - b.top + root.scrollTop
- root.scrollTo({ top, behavior })
- return true
- }
-
- const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
- setActiveMessage(message)
-
- const msgs = visibleUserMessages()
- const index = msgs.findIndex((m) => m.id === message.id)
- if (index !== -1 && index < store.turnStart) {
- setStore("turnStart", index)
- scheduleTurnBackfill()
-
- requestAnimationFrame(() => {
- const el = document.getElementById(anchor(message.id))
- if (!el) {
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- scrollToElement(next, behavior)
- })
- return
- }
- scrollToElement(el, behavior)
- })
-
- updateHash(message.id)
- return
- }
-
- const el = document.getElementById(anchor(message.id))
- if (!el) {
- updateHash(message.id)
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- return
- }
- if (scrollToElement(el, behavior)) {
- updateHash(message.id)
- return
- }
-
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- updateHash(message.id)
- }
-
- const applyHash = (behavior: ScrollBehavior) => {
- const hash = window.location.hash.slice(1)
- if (!hash) {
- autoScroll.forceScrollToBottom()
-
- const el = scroller
- if (el) scheduleScrollState(el)
- return
- }
-
- const match = hash.match(/^message-(.+)$/)
- if (match) {
- autoScroll.pause()
- const msg = visibleUserMessages().find((m) => m.id === match[1])
- if (msg) {
- scrollToMessage(msg, behavior)
- return
- }
-
- // If we have a message hash but the message isn't loaded/rendered yet,
- // don't fall back to "bottom". We'll retry once messages arrive.
- return
- }
-
- const target = document.getElementById(hash)
- if (target) {
- autoScroll.pause()
- scrollToElement(target, behavior)
- return
- }
-
- autoScroll.forceScrollToBottom()
-
- const el = scroller
- if (el) scheduleScrollState(el)
- }
-
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- requestAnimationFrame(() => {
- applyHash("auto")
- })
- })
-
- // Retry message navigation once the target message is actually loaded.
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- // dependencies
- visibleUserMessages().length
- store.turnStart
-
- const targetId =
- ui.pendingMessage ??
- (() => {
- const hash = window.location.hash.slice(1)
- const match = hash.match(/^message-(.+)$/)
- if (!match) return undefined
- return match[1]
- })()
- if (!targetId) return
- if (store.messageId === targetId) return
-
- const msg = visibleUserMessages().find((m) => m.id === targetId)
- if (!msg) return
- if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
- autoScroll.pause()
- requestAnimationFrame(() => scrollToMessage(msg, "auto"))
- })
-
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- const handler = () => requestAnimationFrame(() => applyHash("auto"))
- window.addEventListener("hashchange", handler)
- onCleanup(() => window.removeEventListener("hashchange", handler))
+ const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
+ sessionKey,
+ sessionID: () => params.id,
+ messagesReady,
+ visibleUserMessages,
+ turnStart: () => store.turnStart,
+ currentMessageId: () => store.messageId,
+ pendingMessage: () => ui.pendingMessage,
+ setPendingMessage: (value) => setUi("pendingMessage", value),
+ setActiveMessage,
+ setTurnStart: (value) => setStore("turnStart", value),
+ scheduleTurnBackfill,
+ autoScroll,
+ scroller: () => scroller,
+ anchor,
+ scheduleScrollState,
+ consumePendingMessage: layout.pendingMessage.consume,
})
createEffect(() => {
@@ -2158,20 +1480,17 @@ export default function Page() {
if (!terminal.ready()) return
language.locale()
- const label = (pty: LocalPTY) => {
- const title = pty.title
- const number = pty.titleNumber
- const match = title.match(/^Terminal (\d+)$/)
- const parsed = match ? Number(match[1]) : undefined
- const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
-
- if (title && !isDefaultTitle) return title
- if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
- if (title) return title
- return language.t("terminal.title")
- }
-
- touch(handoff.terminal, params.dir!, terminal.all().map(label))
+ touch(
+ handoff.terminal,
+ params.dir!,
+ terminal.all().map((pty) =>
+ terminalTabLabel({
+ title: pty.title,
+ titleNumber: pty.titleNumber,
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+ }),
+ ),
+ )
})
createEffect(() => {
@@ -2200,34 +1519,14 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
- {/* Mobile tab bar */}
- <Show when={!isDesktop() && params.id}>
- <Tabs class="h-auto">
- <Tabs.List>
- <Tabs.Trigger
- value="session"
- class="w-1/2"
- classes={{ button: "w-full" }}
- onClick={() => setStore("mobileTab", "session")}
- >
- {language.t("session.tab.session")}
- </Tabs.Trigger>
- <Tabs.Trigger
- value="changes"
- class="w-1/2 !border-r-0"
- classes={{ button: "w-full" }}
- onClick={() => setStore("mobileTab", "changes")}
- >
- <Switch>
- <Match when={hasReview()}>
- {language.t("session.review.filesChanged", { count: reviewCount() })}
- </Match>
- <Match when={true}>{language.t("session.review.change.other")}</Match>
- </Switch>
- </Tabs.Trigger>
- </Tabs.List>
- </Tabs>
- </Show>
+ <SessionMobileTabs
+ open={!isDesktop() && !!params.id}
+ hasReview={hasReview()}
+ reviewCount={reviewCount()}
+ onSession={() => setStore("mobileTab", "session")}
+ onChanges={() => setStore("mobileTab", "changes")}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ />
{/* Session panel */}
<div
@@ -2245,347 +1544,79 @@ export default function Page() {
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
- <Show
- when={!mobileChanges()}
- fallback={
- <div class="relative h-full overflow-hidden">
- {reviewContent({
- diffStyle: "unified",
- classes: {
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- },
- loadingClass: "px-4 py-4 text-text-weak",
- emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
- })}
- </div>
- }
- >
- <div class="relative w-full h-full min-w-0">
- <div
- class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
- classList={{
- "opacity-100 translate-y-0 scale-100": ui.scroll.overflow && !ui.scroll.bottom,
- "opacity-0 translate-y-2 scale-95 pointer-events-none":
- !ui.scroll.overflow || ui.scroll.bottom,
- }}
- >
- <button
- class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
- onClick={resumeScroll}
- >
- <Icon name="arrow-down-to-line" />
- </button>
- </div>
- <div
- ref={setScrollRef}
- onWheel={(e) => {
- const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- markScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- markScrollGesture(root)
- return
- }
-
- const max = nested.scrollHeight - nested.clientHeight
- if (max <= 1) {
- markScrollGesture(root)
- return
- }
-
- const delta =
- e.deltaMode === 1
- ? e.deltaY * 40
- : e.deltaMode === 2
- ? e.deltaY * root.clientHeight
- : e.deltaY
- if (!delta) return
-
- if (delta < 0) {
- if (nested.scrollTop + delta <= 0) markScrollGesture(root)
- return
- }
-
- const remaining = max - nested.scrollTop
- if (delta > remaining) markScrollGesture(root)
- }}
- onTouchStart={(e) => {
- touchGesture = e.touches[0]?.clientY
- }}
- onTouchMove={(e) => {
- const next = e.touches[0]?.clientY
- const prev = touchGesture
- touchGesture = next
- if (next === undefined || prev === undefined) return
-
- const delta = prev - next
- if (!delta) return
-
- const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- markScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- markScrollGesture(root)
- return
- }
-
- const max = nested.scrollHeight - nested.clientHeight
- if (max <= 1) {
- markScrollGesture(root)
- return
- }
-
- if (delta < 0) {
- if (nested.scrollTop + delta <= 0) markScrollGesture(root)
- return
- }
-
- const remaining = max - nested.scrollTop
- if (delta > remaining) markScrollGesture(root)
- }}
- onTouchEnd={() => {
- touchGesture = undefined
- }}
- onTouchCancel={() => {
- touchGesture = undefined
- }}
- onPointerDown={(e) => {
- if (e.target !== e.currentTarget) return
- markScrollGesture(e.currentTarget)
- }}
- onScroll={(e) => {
- scheduleScrollState(e.currentTarget)
- if (!hasScrollGesture()) return
- autoScroll.handleScroll()
- markScrollGesture(e.currentTarget)
- if (isDesktop()) scrollSpy.onScroll()
- }}
- onClick={autoScroll.handleInteraction}
- class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
- style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
- >
- <Show when={info()?.title || info()?.parentID}>
- <div
- classList={{
- "sticky top-0 z-30 bg-background-stronger": true,
- "w-full": true,
- "px-4 md:px-6": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- }}
- >
- <div class="h-10 w-full flex items-center justify-between gap-2">
- <div class="flex items-center gap-1 min-w-0 flex-1">
- <Show when={info()?.parentID}>
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={() => {
- navigate(`/${params.dir}/session/${info()?.parentID}`)
- }}
- aria-label={language.t("common.goBack")}
- />
- </Show>
- <Show when={info()?.title || title.editing}>
- <Show
- when={title.editing}
- fallback={
- <h1
- class="text-16-medium text-text-strong truncate min-w-0"
- onDblClick={openTitleEditor}
- >
- {info()?.title}
- </h1>
- }
- >
- <InlineInput
- ref={(el) => {
- titleRef = el
- }}
- value={title.draft}
- disabled={title.saving}
- class="text-16-medium text-text-strong grow-1 min-w-0"
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- if (event.key === "Enter") {
- event.preventDefault()
- void saveTitleEditor()
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeTitleEditor()
- }
- }}
- onBlur={() => closeTitleEditor()}
- />
- </Show>
- </Show>
- </div>
- <Show when={params.id}>
- {(id) => (
- <div class="shrink-0 flex items-center">
- <DropdownMenu
- open={title.menuOpen}
- onOpenChange={(open) => setTitle("menuOpen", open)}
- >
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- }}
- >
- <DropdownMenu.Item
- onSelect={() => {
- setTitle({ pendingRename: true, menuOpen: false })
- }}
- >
- <DropdownMenu.ItemLabel>
- {language.t("common.rename")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
- <DropdownMenu.ItemLabel>
- {language.t("common.archive")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
- >
- <DropdownMenu.ItemLabel>
- {language.t("common.delete")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- )}
- </Show>
- </div>
- </div>
- </Show>
-
- <div
- ref={(el) => {
- content = el
- autoScroll.contentRef(el)
-
- const root = scroller
- if (root) scheduleScrollState(root)
- }}
- role="log"
- class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
- classList={{
- "w-full": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- "mt-0.5": centered(),
- "mt-0": !centered(),
- }}
- >
- <Show when={store.turnStart > 0}>
- <div class="w-full flex justify-center">
- <Button
- variant="ghost"
- size="large"
- class="text-12-medium opacity-50"
- onClick={() => setStore("turnStart", 0)}
- >
- {language.t("session.messages.renderEarlier")}
- </Button>
- </div>
- </Show>
- <Show when={historyMore()}>
- <div class="w-full flex justify-center">
- <Button
- variant="ghost"
- size="large"
- class="text-12-medium opacity-50"
- disabled={historyLoading()}
- onClick={() => {
- const id = params.id
- if (!id) return
- setStore("turnStart", 0)
- sync.session.history.loadMore(id)
- }}
- >
- {historyLoading()
- ? language.t("session.messages.loadingEarlier")
- : language.t("session.messages.loadEarlier")}
- </Button>
- </div>
- </Show>
- <For each={renderedUserMessages()}>
- {(message) => {
- if (import.meta.env.DEV) {
- onMount(() => {
- const id = params.id
- if (!id) return
- navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
- })
- }
-
- return (
- <div
- id={anchor(message.id)}
- data-message-id={message.id}
- ref={(el) => {
- scrollSpy.register(el, message.id)
- onCleanup(() => scrollSpy.unregister(message.id))
- }}
- classList={{
- "min-w-0 w-full max-w-full": true,
- "md:max-w-200 3xl:max-w-[1200px]": centered(),
- }}
- >
- <SessionTurn
- sessionID={params.id!}
- messageID={message.id}
- lastUserMessageID={lastUserMessage()?.id}
- stepsExpanded={store.expanded[message.id] ?? false}
- onStepsExpandedToggle={() =>
- setStore("expanded", message.id, (open: boolean | undefined) => !open)
- }
- classes={{
- root: "min-w-0 w-full relative",
- content: "flex flex-col justify-between !overflow-visible",
- container: "w-full px-4 md:px-6",
- }}
- />
- </div>
- )
- }}
- </For>
- </div>
- </div>
- </div>
- </Show>
+ <MessageTimeline
+ mobileChanges={mobileChanges()}
+ mobileFallback={reviewContent({
+ diffStyle: "unified",
+ classes: {
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ header: "px-4",
+ container: "px-4",
+ },
+ loadingClass: "px-4 py-4 text-text-weak",
+ emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
+ scroll={ui.scroll}
+ onResumeScroll={resumeScroll}
+ setScrollRef={setScrollRef}
+ onScheduleScrollState={scheduleScrollState}
+ onAutoScrollHandleScroll={autoScroll.handleScroll}
+ onMarkScrollGesture={markScrollGesture}
+ hasScrollGesture={hasScrollGesture}
+ isDesktop={isDesktop()}
+ onScrollSpyScroll={scrollSpy.onScroll}
+ onAutoScrollInteraction={autoScroll.handleInteraction}
+ showHeader={!!(info()?.title || info()?.parentID)}
+ centered={centered()}
+ title={info()?.title}
+ parentID={info()?.parentID}
+ openTitleEditor={openTitleEditor}
+ closeTitleEditor={closeTitleEditor}
+ saveTitleEditor={saveTitleEditor}
+ titleRef={(el) => {
+ titleRef = el
+ }}
+ titleState={title}
+ onTitleDraft={(value) => setTitle("draft", value)}
+ onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
+ onTitlePendingRename={(value) => setTitle("pendingRename", value)}
+ onNavigateParent={() => {
+ navigate(`/${params.dir}/session/${info()?.parentID}`)
+ }}
+ sessionID={params.id!}
+ onArchiveSession={(sessionID) => void archiveSession(sessionID)}
+ onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ setContentRef={(el) => {
+ content = el
+ autoScroll.contentRef(el)
+
+ const root = scroller
+ if (root) scheduleScrollState(root)
+ }}
+ turnStart={store.turnStart}
+ onRenderEarlier={() => setStore("turnStart", 0)}
+ historyMore={historyMore()}
+ historyLoading={historyLoading()}
+ onLoadEarlier={() => {
+ const id = params.id
+ if (!id) return
+ setStore("turnStart", 0)
+ sync.session.history.loadMore(id)
+ }}
+ renderedUserMessages={renderedUserMessages()}
+ anchor={anchor}
+ onRegisterMessage={scrollSpy.register}
+ onUnregisterMessage={scrollSpy.unregister}
+ onFirstTurnMount={() => {
+ const id = params.id
+ if (!id) return
+ navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
+ }}
+ lastUserMessageID={lastUserMessage()?.id}
+ expanded={store.expanded}
+ onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
+ />
</Show>
</Match>
<Match when={true}>
@@ -2610,115 +1641,27 @@ export default function Page() {
</Switch>
</div>
- {/* Prompt input */}
- <div
- ref={(el) => (promptDock = el)}
- class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
- >
- <div
- classList={{
- "w-full px-4 pointer-events-auto": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- }}
- >
- <Show when={questionRequest()} keyed>
- {(req) => {
- const count = req.questions.length
- const subtitle =
- count === 0
- ? ""
- : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
- return (
- <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
- <BasicTool
- icon="bubble-5"
- locked
- defaultOpen
- trigger={{
- title: language.t("ui.tool.questions"),
- subtitle,
- }}
- />
- <QuestionDock request={req} />
- </div>
- )
- }}
- </Show>
-
- <Show when={permRequest()} keyed>
- {(perm) => (
- <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
- <BasicTool
- icon="checklist"
- locked
- defaultOpen
- trigger={{
- title: language.t("notification.permission.title"),
- subtitle:
- perm.permission === "doom_loop"
- ? language.t("settings.permissions.tool.doom_loop.title")
- : perm.permission,
- }}
- >
- <Show when={perm.patterns.length > 0}>
- <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
- <For each={perm.patterns}>
- {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
- </For>
- </div>
- </Show>
- <Show when={perm.permission === "doom_loop"}>
- <div class="text-12-regular text-text-weak pb-2 px-3">
- {language.t("settings.permissions.tool.doom_loop.description")}
- </div>
- </Show>
- </BasicTool>
- <div data-component="permission-prompt">
- <div data-slot="permission-actions">
- <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}>
- {language.t("ui.permission.deny")}
- </Button>
- <Button
- variant="secondary"
- size="small"
- onClick={() => decide("always")}
- disabled={ui.responding}
- >
- {language.t("ui.permission.allowAlways")}
- </Button>
- <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}>
- {language.t("ui.permission.allowOnce")}
- </Button>
- </div>
- </div>
- </div>
- )}
- </Show>
-
- <Show when={!blocked()}>
- <Show
- when={prompt.ready()}
- fallback={
- <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
- {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
- </div>
- }
- >
- <PromptInput
- ref={(el) => {
- inputRef = el
- }}
- newSessionWorktree={newSessionWorktree()}
- onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
- onSubmit={() => {
- comments.clear()
- resumeScroll()
- }}
- />
- </Show>
- </Show>
- </div>
- </div>
+ <SessionPromptDock
+ centered={centered()}
+ questionRequest={questionRequest}
+ permissionRequest={permRequest}
+ blocked={blocked()}
+ promptReady={prompt.ready()}
+ handoffPrompt={handoff.session.get(sessionKey())?.prompt}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ responding={ui.responding}
+ onDecide={decide}
+ inputRef={(el) => {
+ inputRef = el
+ }}
+ newSessionWorktree={newSessionWorktree()}
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+ onSubmit={() => {
+ comments.clear()
+ resumeScroll()
+ }}
+ setPromptDockRef={(el) => (promptDock = el)}
+ />
<Show when={isDesktop() && view().reviewPanel.opened()}>
<ResizeHandle
@@ -2731,925 +1674,62 @@ export default function Page() {
</Show>
</div>
- {/* Desktop side panel - hidden on mobile */}
- <Show when={isDesktop() && view().reviewPanel.opened()}>
- <aside
- id="review-panel"
- aria-label={language.t("session.panel.reviewAndFiles")}
- class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
- >
- <div class="flex-1 min-w-0 h-full">
- <Show
- when={layout.fileTree.opened() && fileTreeTab() === "changes"}
- fallback={
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <Tabs value={activeTab()} onChange={openTab}>
- <div class="sticky top-0 shrink-0 flex">
- <Tabs.List
- ref={(el: HTMLDivElement) => {
- let scrollTimeout: number | undefined
- let prevScrollWidth = el.scrollWidth
- let prevContextOpen = contextOpen()
-
- const handler = () => {
- if (scrollTimeout !== undefined) clearTimeout(scrollTimeout)
- scrollTimeout = window.setTimeout(() => {
- const scrollWidth = el.scrollWidth
- const clientWidth = el.clientWidth
- const currentContextOpen = contextOpen()
-
- // Only scroll when a tab is added (width increased), not on removal
- if (scrollWidth > prevScrollWidth) {
- if (!prevContextOpen && currentContextOpen) {
- // Context tab was opened, scroll to first
- el.scrollTo({
- left: 0,
- behavior: "smooth",
- })
- } else if (scrollWidth > clientWidth) {
- // File tab was added, scroll to rightmost
- el.scrollTo({
- left: scrollWidth - clientWidth,
- behavior: "smooth",
- })
- }
- }
- // When width decreases (tab removed), don't scroll - let browser handle it naturally
-
- prevScrollWidth = scrollWidth
- prevContextOpen = currentContextOpen
- }, 0)
- }
-
- const wheelHandler = (e: WheelEvent) => {
- // Enable horizontal scrolling with mouse wheel
- if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
- el.scrollLeft += e.deltaY > 0 ? 50 : -50
- e.preventDefault()
- }
- }
-
- el.addEventListener("wheel", wheelHandler, { passive: false })
-
- const observer = new MutationObserver(handler)
- observer.observe(el, { childList: true })
-
- onCleanup(() => {
- el.removeEventListener("wheel", wheelHandler)
- observer.disconnect()
- if (scrollTimeout !== undefined) clearTimeout(scrollTimeout)
- })
- }}
- >
- <Show when={reviewTab()}>
- <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
- <div class="flex items-center gap-1.5">
- <div>{language.t("session.tab.review")}</div>
- <Show when={hasReview()}>
- <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {reviewCount()}
- </div>
- </Show>
- </div>
- </Tabs.Trigger>
- </Show>
- <Show when={contextOpen()}>
- <Tabs.Trigger
- value="context"
- closeButton={
- <Tooltip value={language.t("common.closeTab")} placement="bottom">
- <IconButton
- icon="close-small"
- variant="ghost"
- class="h-5 w-5"
- onClick={() => tabs().close("context")}
- aria-label={language.t("common.closeTab")}
- />
- </Tooltip>
- }
- hideCloseButton
- onMiddleClick={() => tabs().close("context")}
- >
- <div class="flex items-center gap-2">
- <SessionContextUsage variant="indicator" />
- <div>{language.t("session.tab.context")}</div>
- </div>
- </Tabs.Trigger>
- </Show>
- <SortableProvider ids={openedTabs()}>
- <For each={openedTabs()}>
- {(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}
- </For>
- </SortableProvider>
- <StickyAddButton>
- <TooltipKeybind
- title={language.t("command.file.open")}
- keybind={command.keybind("file.open")}
- class="flex items-center"
- >
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() =>
- dialog.show(() => <DialogSelectFile mode="files" onOpenFile={() => showAllFiles()} />)
- }
- aria-label={language.t("command.file.open")}
- />
- </TooltipKeybind>
- </StickyAddButton>
- </Tabs.List>
- </div>
-
- <Show when={reviewTab()}>
- <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "review"}>{reviewPanel()}</Show>
- </Tabs.Content>
- </Show>
-
- <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "empty"}>
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">
- {language.t("session.files.selectToOpen")}
- </div>
- </div>
- </div>
- </Show>
- </Tabs.Content>
-
- <Show when={contextOpen()}>
- <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "context"}>
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionContextTab
- messages={messages}
- visibleUserMessages={visibleUserMessages}
- view={view}
- info={info}
- />
- </div>
- </Show>
- </Tabs.Content>
- </Show>
-
- <Show when={activeFileTab()} keyed>
- {(tab) => {
- let scroll: HTMLDivElement | undefined
- let scrollFrame: number | undefined
- let pending: { x: number; y: number } | undefined
- let codeScroll: HTMLElement[] = []
-
- const path = createMemo(() => file.pathFromTab(tab))
- const state = createMemo(() => {
- const p = path()
- if (!p) return
- return file.get(p)
- })
- const contents = createMemo(() => state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
- const isImage = createMemo(() => {
- const c = state()?.content
- return (
- c?.encoding === "base64" &&
- c?.mimeType?.startsWith("image/") &&
- c?.mimeType !== "image/svg+xml"
- )
- })
- const isSvg = createMemo(() => {
- const c = state()?.content
- return c?.mimeType === "image/svg+xml"
- })
- const isBinary = createMemo(() => state()?.content?.type === "binary")
- const svgContent = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding !== "base64") return c.content
- return decode64(c.content)
- })
-
- const svgDecodeFailed = createMemo(() => {
- if (!isSvg()) return false
- const c = state()?.content
- if (!c) return false
- if (c.encoding !== "base64") return false
- return svgContent() === undefined
- })
-
- const svgToast = { shown: false }
- createEffect(() => {
- if (!svgDecodeFailed()) return
- if (svgToast.shown) return
- svgToast.shown = true
- showToast({
- variant: "error",
- title: language.t("toast.file.loadFailed.title"),
- description: "Invalid base64 content.",
- })
- })
- const svgPreviewUrl = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
- })
- const imageDataUrl = createMemo(() => {
- if (!isImage()) return
- const c = state()?.content
- return `data:${c?.mimeType};base64,${c?.content}`
- })
- const selectedLines = createMemo(() => {
- const p = path()
- if (!p) return null
- if (file.ready()) return file.selectedLines(p) ?? null
- return handoff.session.get(sessionKey())?.files[p] ?? null
- })
-
- let wrap: HTMLDivElement | undefined
-
- const fileComments = createMemo(() => {
- const p = path()
- if (!p) return []
- return comments.list(p)
- })
-
- const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
-
- const [note, setNote] = createStore({
- openedComment: null as string | null,
- commenting: null as SelectedLineRange | null,
- draft: "",
- positions: {} as Record<string, number>,
- draftTop: undefined as number | undefined,
- })
-
- const openedComment = () => note.openedComment
- const setOpenedComment = (
- value:
- | typeof note.openedComment
- | ((value: typeof note.openedComment) => typeof note.openedComment),
- ) => setNote("openedComment", value)
-
- const commenting = () => note.commenting
- const setCommenting = (
- value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting),
- ) => setNote("commenting", value)
-
- const draft = () => note.draft
- const setDraft = (
- value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft),
- ) => setNote("draft", value)
-
- const positions = () => note.positions
- const setPositions = (
- value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions),
- ) => setNote("positions", value)
-
- const draftTop = () => note.draftTop
- const setDraftTop = (
- value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop),
- ) => setNote("draftTop", value)
-
- const commentLabel = (range: SelectedLineRange) => {
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
- if (start === end) return `line ${start}`
- return `lines ${start}-${end}`
- }
-
- const getRoot = () => {
- const el = wrap
- if (!el) return
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const root = host.shadowRoot
- if (!root) return
-
- return root
- }
-
- const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
- const line = Math.max(range.start, range.end)
- const node = root.querySelector(`[data-line="${line}"]`)
- if (!(node instanceof HTMLElement)) return
- return node
- }
-
- const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
- const wrapperRect = wrapper.getBoundingClientRect()
- const rect = marker.getBoundingClientRect()
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
- }
-
- const updateComments = () => {
- const el = wrap
- const root = getRoot()
- if (!el || !root) {
- setPositions({})
- setDraftTop(undefined)
- return
- }
-
- const next: Record<string, number> = {}
- for (const comment of fileComments()) {
- const marker = findMarker(root, comment.selection)
- if (!marker) continue
- next[comment.id] = markerTop(el, marker)
- }
-
- setPositions(next)
-
- const range = commenting()
- if (!range) {
- setDraftTop(undefined)
- return
- }
-
- const marker = findMarker(root, range)
- if (!marker) {
- setDraftTop(undefined)
- return
- }
-
- setDraftTop(markerTop(el, marker))
- }
-
- const scheduleComments = () => {
- requestAnimationFrame(updateComments)
- }
-
- createEffect(() => {
- fileComments()
- scheduleComments()
- })
-
- createEffect(() => {
- const range = commenting()
- scheduleComments()
- if (!range) return
- setDraft("")
- })
-
- createEffect(() => {
- const focus = comments.focus()
- const p = path()
- if (!focus || !p) return
- if (focus.file !== p) return
- if (activeTab() !== tab) return
-
- const target = fileComments().find((comment) => comment.id === focus.id)
- if (!target) return
-
- setOpenedComment(target.id)
- setCommenting(null)
- file.setSelectedLines(p, target.selection)
- requestAnimationFrame(() => comments.clearFocus())
- })
-
- const renderCode = (source: string, wrapperClass: string) => (
- <div
- ref={(el) => {
- wrap = el
- scheduleComments()
- }}
- class={`relative overflow-hidden ${wrapperClass}`}
- >
- <Dynamic
- component={codeComponent}
- file={{
- name: path() ?? "",
- contents: source,
- cacheKey: cacheKey(),
- }}
- enableLineSelection
- selectedLines={selectedLines()}
- commentedLines={commentedLines()}
- onRendered={() => {
- requestAnimationFrame(restoreScroll)
- requestAnimationFrame(scheduleComments)
- }}
- onLineSelected={(range: SelectedLineRange | null) => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- if (!range) setCommenting(null)
- }}
- onLineSelectionEnd={(range: SelectedLineRange | null) => {
- if (!range) {
- setCommenting(null)
- return
- }
-
- setOpenedComment(null)
- setCommenting(range)
- }}
- overflow="scroll"
- class="select-text"
- />
- <For each={fileComments()}>
- {(comment) => (
- <LineCommentView
- id={comment.id}
- top={positions()[comment.id]}
- open={openedComment() === comment.id}
- comment={comment.comment}
- selection={commentLabel(comment.selection)}
- onMouseEnter={() => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, comment.selection)
- }}
- onClick={() => {
- const p = path()
- if (!p) return
- setCommenting(null)
- setOpenedComment((current) => (current === comment.id ? null : comment.id))
- file.setSelectedLines(p, comment.selection)
- }}
- />
- )}
- </For>
- <Show when={commenting()}>
- {(range) => (
- <Show when={draftTop() !== undefined}>
- <LineCommentEditor
- top={draftTop()}
- value={draft()}
- selection={commentLabel(range())}
- onInput={(value) => setDraft(value)}
- onCancel={() => setCommenting(null)}
- onSubmit={(value) => {
- const p = path()
- if (!p) return
- addCommentToContext({
- file: p,
- selection: range(),
- comment: value,
- origin: "file",
- })
- setCommenting(null)
- }}
- onPopoverFocusOut={(e: FocusEvent) => {
- const current = e.currentTarget as HTMLDivElement
- const target = e.relatedTarget
- if (target instanceof Node && current.contains(target)) return
-
- setTimeout(() => {
- if (!document.activeElement || !current.contains(document.activeElement)) {
- setCommenting(null)
- }
- }, 0)
- }}
- />
- </Show>
- )}
- </Show>
- </div>
- )
-
- const getCodeScroll = () => {
- const el = scroll
- if (!el) return []
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return []
-
- const root = host.shadowRoot
- if (!root) return []
-
- return Array.from(root.querySelectorAll("[data-code]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
- )
- }
-
- const queueScrollUpdate = (next: { x: number; y: number }) => {
- pending = next
- if (scrollFrame !== undefined) return
-
- scrollFrame = requestAnimationFrame(() => {
- scrollFrame = undefined
-
- const next = pending
- pending = undefined
- if (!next) return
-
- view().setScroll(tab, next)
- })
- }
-
- const handleCodeScroll = (event: Event) => {
- const el = scroll
- if (!el) return
-
- const target = event.currentTarget
- if (!(target instanceof HTMLElement)) return
-
- queueScrollUpdate({
- x: target.scrollLeft,
- y: el.scrollTop,
- })
- }
-
- const syncCodeScroll = () => {
- const next = getCodeScroll()
- if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
-
- for (const item of codeScroll) {
- item.removeEventListener("scroll", handleCodeScroll)
- }
-
- codeScroll = next
-
- for (const item of codeScroll) {
- item.addEventListener("scroll", handleCodeScroll)
- }
- }
-
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
-
- const s = view()?.scroll(tab)
- if (!s) return
-
- syncCodeScroll()
-
- if (codeScroll.length > 0) {
- for (const item of codeScroll) {
- if (item.scrollLeft !== s.x) item.scrollLeft = s.x
- }
- }
-
- if (el.scrollTop !== s.y) el.scrollTop = s.y
-
- if (codeScroll.length > 0) return
-
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
-
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- if (codeScroll.length === 0) syncCodeScroll()
-
- queueScrollUpdate({
- x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- })
- }
-
- createEffect(
- on(
- () => state()?.loaded,
- (loaded) => {
- if (!loaded) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => file.ready(),
- (ready) => {
- if (!ready) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => tabs().active() === tab,
- (active) => {
- if (!active) return
- if (!state()?.loaded) return
- requestAnimationFrame(restoreScroll)
- },
- ),
- )
-
- onCleanup(() => {
- for (const item of codeScroll) {
- item.removeEventListener("scroll", handleCodeScroll)
- }
-
- if (scrollFrame === undefined) return
- cancelAnimationFrame(scrollFrame)
- })
-
- return (
- <Tabs.Content
- value={tab}
- class="mt-3 relative"
- ref={(el: HTMLDivElement) => {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
- <Switch>
- <Match when={state()?.loaded && isImage()}>
- <div class="px-6 py-4 pb-40">
- <img
- src={imageDataUrl()}
- alt={path()}
- class="max-w-full"
- onLoad={() => requestAnimationFrame(restoreScroll)}
- />
- </div>
- </Match>
- <Match when={state()?.loaded && isSvg()}>
- <div class="flex flex-col gap-4 px-6 py-4">
- {renderCode(svgContent() ?? "", "")}
- <Show when={svgPreviewUrl()}>
- <div class="flex justify-center pb-40">
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
- </div>
- </Show>
- </div>
- </Match>
- <Match when={state()?.loaded && isBinary()}>
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="flex flex-col gap-2 max-w-md">
- <div class="text-14-semibold text-text-strong truncate">
- {path()?.split("/").pop()}
- </div>
- <div class="text-14-regular text-text-weak">
- {language.t("session.files.binaryContent")}
- </div>
- </div>
- </div>
- </Match>
- <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
- <Match when={state()?.loading}>
- <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
- </Match>
- <Match when={state()?.error}>
- {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
- </Match>
- </Switch>
- </Tabs.Content>
- )
- }}
- </Show>
- </Tabs>
- <DragOverlay>
- <Show when={store.activeDraggable}>
- {(tab) => {
- const path = createMemo(() => file.pathFromTab(tab()))
- return (
- <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
- <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
- </div>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- }
- >
- {reviewPanel()}
- </Show>
- </div>
-
- <Show when={layout.fileTree.opened()}>
- <div
- id="file-tree-panel"
- class="relative shrink-0 h-full"
- style={{ width: `${layout.fileTree.width()}px` }}
- >
- <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
- <Tabs
- variant="pill"
- value={fileTreeTab()}
- onChange={setFileTreeTabValue}
- class="h-full"
- data-scope="filetree"
- >
- <Tabs.List>
- <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
- {reviewCount()}{" "}
- {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
- </Tabs.Trigger>
- <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
- {language.t("session.files.all")}
- </Tabs.Trigger>
- </Tabs.List>
- <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
- <Switch>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={
- <div class="px-2 py-2 text-12-regular text-text-weak">
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
- </div>
- }
- >
- <FileTree
- path=""
- allowed={diffFiles()}
- kinds={kinds()}
- draggable={false}
- active={tree.activeDiff}
- onFileClick={(node) => focusReviewDiff(node.path)}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="mt-8 text-center text-12-regular text-text-weak">
- {language.t("session.review.noChanges")}
- </div>
- </Match>
- </Switch>
- </Tabs.Content>
- <Tabs.Content value="all" class="bg-background-base px-3 py-0">
- <FileTree
- path=""
- modified={diffFiles()}
- kinds={kinds()}
- onFileClick={(node) => openTab(file.tab(node.path))}
- />
- </Tabs.Content>
- </Tabs>
- </div>
- <ResizeHandle
- direction="horizontal"
- edge="start"
- size={layout.fileTree.width()}
- min={200}
- max={480}
- collapseThreshold={160}
- onResize={layout.fileTree.resize}
- onCollapse={layout.fileTree.close}
- />
- </div>
- </Show>
- </aside>
- </Show>
+ <SessionSidePanel
+ open={isDesktop() && view().reviewPanel.opened()}
+ language={language}
+ layout={layout}
+ command={command}
+ dialog={dialog}
+ file={file}
+ comments={comments}
+ sync={sync}
+ hasReview={hasReview()}
+ reviewCount={reviewCount()}
+ reviewTab={reviewTab()}
+ contextOpen={contextOpen}
+ openedTabs={openedTabs}
+ activeTab={activeTab}
+ activeFileTab={activeFileTab}
+ tabs={tabs}
+ openTab={openTab}
+ showAllFiles={showAllFiles}
+ reviewPanel={reviewPanel}
+ messages={messages as () => unknown[]}
+ visibleUserMessages={visibleUserMessages as () => unknown[]}
+ view={view}
+ info={info as () => unknown}
+ handoffFiles={() => handoff.session.get(sessionKey())?.files}
+ codeComponent={codeComponent}
+ addCommentToContext={addCommentToContext}
+ activeDraggable={() => store.activeDraggable}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ fileTreeTab={fileTreeTab}
+ setFileTreeTabValue={setFileTreeTabValue}
+ diffsReady={diffsReady()}
+ diffFiles={diffFiles()}
+ kinds={kinds()}
+ activeDiff={tree.activeDiff}
+ focusReviewDiff={focusReviewDiff}
+ />
</div>
- <Show when={isDesktop() && view().terminal.opened()}>
- <div
- id="terminal-panel"
- role="region"
- aria-label={language.t("terminal.title")}
- class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
- style={{ height: `${layout.terminal.height()}px` }}
- >
- <ResizeHandle
- direction="vertical"
- size={layout.terminal.height()}
- min={100}
- max={window.innerHeight * 0.6}
- collapseThreshold={50}
- onResize={layout.terminal.resize}
- onCollapse={view().terminal.close}
- />
- <Show
- when={terminal.ready()}
- fallback={
- <div class="flex flex-col h-full pointer-events-none">
- <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
- <For each={handoff.terminal.get(params.dir!) ?? []}>
- {(title) => (
- <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
- {title}
- </div>
- )}
- </For>
- <div class="flex-1" />
- <div class="text-text-weak pr-2">
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
- </div>
- </div>
- <div class="flex-1 flex items-center justify-center text-text-weak">
- {language.t("terminal.loading")}
- </div>
- </div>
- }
- >
- <DragDropProvider
- onDragStart={handleTerminalDragStart}
- onDragEnd={handleTerminalDragEnd}
- onDragOver={handleTerminalDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <div class="flex flex-col h-full">
- <Tabs
- variant="alt"
- value={terminal.active()}
- onChange={(id) => {
- // Only switch tabs if not in the middle of starting edit mode
- terminal.open(id)
- }}
- class="!h-auto !flex-none"
- >
- <Tabs.List class="h-10">
- <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
- <For each={terminal.all()}>
- {(pty) => (
- <SortableTerminalTab
- terminal={pty}
- onClose={() => {
- view().terminal.close()
- setUi("autoCreated", false)
- }}
- />
- )}
- </For>
- </SortableProvider>
- <div class="h-full flex items-center justify-center">
- <TooltipKeybind
- title={language.t("command.terminal.new")}
- keybind={command.keybind("terminal.new")}
- class="flex items-center"
- >
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={terminal.new}
- aria-label={language.t("command.terminal.new")}
- />
- </TooltipKeybind>
- </div>
- </Tabs.List>
- </Tabs>
- <div class="flex-1 min-h-0 relative">
- <For each={terminal.all()}>
- {(pty) => (
- <div
- id={`terminal-wrapper-${pty.id}`}
- class="absolute inset-0"
- style={{
- display: terminal.active() === pty.id ? "block" : "none",
- }}
- >
- <Show when={pty.id} keyed>
- <Terminal
- pty={pty}
- onCleanup={terminal.update}
- onConnectError={() => terminal.clone(pty.id)}
- />
- </Show>
- </div>
- )}
- </For>
- </div>
- </div>
- <DragOverlay>
- <Show when={store.activeTerminalDraggable}>
- {(draggedId) => {
- const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
- return (
- <Show when={pty()}>
- {(t) => (
- <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
- {(() => {
- const title = t().title
- const number = t().titleNumber
- const match = title.match(/^Terminal (\d+)$/)
- const parsed = match ? Number(match[1]) : undefined
- const isDefaultTitle =
- Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
-
- if (title && !isDefaultTitle) return title
- if (Number.isFinite(number) && number > 0)
- return language.t("terminal.title.numbered", { number })
- if (title) return title
- return language.t("terminal.title")
- })()}
- </div>
- )}
- </Show>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- </Show>
- </div>
- </Show>
+ <TerminalPanel
+ open={isDesktop() && view().terminal.opened()}
+ height={layout.terminal.height()}
+ resize={layout.terminal.resize}
+ close={view().terminal.close}
+ terminal={terminal}
+ language={language}
+ command={command}
+ handoff={() => handoff.terminal.get(params.dir!) ?? []}
+ activeTerminalDraggable={() => store.activeTerminalDraggable}
+ handleTerminalDragStart={handleTerminalDragStart}
+ handleTerminalDragOver={handleTerminalDragOver}
+ handleTerminalDragEnd={handleTerminalDragEnd}
+ onCloseTab={() => setUi("autoCreated", false)}
+ />
</div>
)
}
diff --git a/packages/app/src/pages/session/file-tab-scroll.test.ts b/packages/app/src/pages/session/file-tab-scroll.test.ts
new file mode 100644
index 000000000..89e0dcc8f
--- /dev/null
+++ b/packages/app/src/pages/session/file-tab-scroll.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test"
+import { nextTabListScrollLeft } from "./file-tab-scroll"
+
+describe("nextTabListScrollLeft", () => {
+ test("does not scroll when width shrinks", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 500,
+ scrollWidth: 420,
+ clientWidth: 300,
+ prevContextOpen: false,
+ contextOpen: false,
+ })
+
+ expect(left).toBeUndefined()
+ })
+
+ test("scrolls to start when context tab opens", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 400,
+ scrollWidth: 500,
+ clientWidth: 320,
+ prevContextOpen: false,
+ contextOpen: true,
+ })
+
+ expect(left).toBe(0)
+ })
+
+ test("scrolls to right edge for new file tabs", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 500,
+ scrollWidth: 780,
+ clientWidth: 300,
+ prevContextOpen: true,
+ contextOpen: true,
+ })
+
+ expect(left).toBe(480)
+ })
+})
diff --git a/packages/app/src/pages/session/file-tab-scroll.ts b/packages/app/src/pages/session/file-tab-scroll.ts
new file mode 100644
index 000000000..b69188d40
--- /dev/null
+++ b/packages/app/src/pages/session/file-tab-scroll.ts
@@ -0,0 +1,67 @@
+type Input = {
+ prevScrollWidth: number
+ scrollWidth: number
+ clientWidth: number
+ prevContextOpen: boolean
+ contextOpen: boolean
+}
+
+export const nextTabListScrollLeft = (input: Input) => {
+ if (input.scrollWidth <= input.prevScrollWidth) return
+ if (!input.prevContextOpen && input.contextOpen) return 0
+ if (input.scrollWidth <= input.clientWidth) return
+ return input.scrollWidth - input.clientWidth
+}
+
+export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => {
+ let frame: number | undefined
+ let prevScrollWidth = input.el.scrollWidth
+ let prevContextOpen = input.contextOpen()
+
+ const update = () => {
+ const scrollWidth = input.el.scrollWidth
+ const clientWidth = input.el.clientWidth
+ const contextOpen = input.contextOpen()
+ const left = nextTabListScrollLeft({
+ prevScrollWidth,
+ scrollWidth,
+ clientWidth,
+ prevContextOpen,
+ contextOpen,
+ })
+
+ if (left !== undefined) {
+ input.el.scrollTo({
+ left,
+ behavior: "smooth",
+ })
+ }
+
+ prevScrollWidth = scrollWidth
+ prevContextOpen = contextOpen
+ }
+
+ const schedule = () => {
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+ update()
+ })
+ }
+
+ const onWheel = (e: WheelEvent) => {
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
+ input.el.scrollLeft += e.deltaY > 0 ? 50 : -50
+ e.preventDefault()
+ }
+
+ input.el.addEventListener("wheel", onWheel, { passive: false })
+ const observer = new MutationObserver(schedule)
+ observer.observe(input.el, { childList: true })
+
+ return () => {
+ input.el.removeEventListener("wheel", onWheel)
+ observer.disconnect()
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ }
+}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
new file mode 100644
index 000000000..0c8281a66
--- /dev/null
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -0,0 +1,516 @@
+import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Dynamic } from "solid-js/web"
+import { checksum } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
+import { showToast } from "@opencode-ai/ui/toast"
+import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useLayout } from "@/context/layout"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useComments } from "@/context/comments"
+import { useLanguage } from "@/context/language"
+
+export function FileTabContent(props: {
+ tab: string
+ activeTab: () => string
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+ file: ReturnType<typeof useFile>
+ comments: ReturnType<typeof useComments>
+ language: ReturnType<typeof useLanguage>
+ codeComponent: NonNullable<ValidComponent>
+ addCommentToContext: (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => void
+}) {
+ let scroll: HTMLDivElement | undefined
+ let scrollFrame: number | undefined
+ let pending: { x: number; y: number } | undefined
+ let codeScroll: HTMLElement[] = []
+
+ const path = createMemo(() => props.file.pathFromTab(props.tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return props.file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => checksum(contents()))
+ const isImage = createMemo(() => {
+ const c = state()?.content
+ return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
+ })
+ const isSvg = createMemo(() => {
+ const c = state()?.content
+ return c?.mimeType === "image/svg+xml"
+ })
+ const isBinary = createMemo(() => state()?.content?.type === "binary")
+ const svgContent = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding !== "base64") return c.content
+ return decode64(c.content)
+ })
+
+ const svgDecodeFailed = createMemo(() => {
+ if (!isSvg()) return false
+ const c = state()?.content
+ if (!c) return false
+ if (c.encoding !== "base64") return false
+ return svgContent() === undefined
+ })
+
+ const svgToast = { shown: false }
+ createEffect(() => {
+ if (!svgDecodeFailed()) return
+ if (svgToast.shown) return
+ svgToast.shown = true
+ showToast({
+ variant: "error",
+ title: props.language.t("toast.file.loadFailed.title"),
+ description: "Invalid base64 content.",
+ })
+ })
+ const svgPreviewUrl = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+ })
+ const imageDataUrl = createMemo(() => {
+ if (!isImage()) return
+ const c = state()?.content
+ return `data:${c?.mimeType};base64,${c?.content}`
+ })
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ if (props.file.ready()) return props.file.selectedLines(p) ?? null
+ return props.handoffFiles()?.[p] ?? null
+ })
+
+ let wrap: HTMLDivElement | undefined
+
+ const fileComments = createMemo(() => {
+ const p = path()
+ if (!p) return []
+ return props.comments.list(p)
+ })
+
+ const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+ const [note, setNote] = createStore({
+ openedComment: null as string | null,
+ commenting: null as SelectedLineRange | null,
+ draft: "",
+ positions: {} as Record<string, number>,
+ draftTop: undefined as number | undefined,
+ })
+
+ const openedComment = () => note.openedComment
+ const setOpenedComment = (
+ value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
+ ) => setNote("openedComment", value)
+
+ const commenting = () => note.commenting
+ const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
+ setNote("commenting", value)
+
+ const draft = () => note.draft
+ const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
+ setNote("draft", value)
+
+ const positions = () => note.positions
+ const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
+ setNote("positions", value)
+
+ const draftTop = () => note.draftTop
+ const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
+ setNote("draftTop", value)
+
+ const commentLabel = (range: SelectedLineRange) => {
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start === end) return `line ${start}`
+ return `lines ${start}-${end}`
+ }
+
+ const getRoot = () => {
+ const el = wrap
+ if (!el) return
+
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
+
+ const root = host.shadowRoot
+ if (!root) return
+
+ return root
+ }
+
+ const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
+ const line = Math.max(range.start, range.end)
+ const node = root.querySelector(`[data-line="${line}"]`)
+ if (!(node instanceof HTMLElement)) return
+ return node
+ }
+
+ const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
+ const wrapperRect = wrapper.getBoundingClientRect()
+ const rect = marker.getBoundingClientRect()
+ return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+ }
+
+ const updateComments = () => {
+ const el = wrap
+ const root = getRoot()
+ if (!el || !root) {
+ setPositions({})
+ setDraftTop(undefined)
+ return
+ }
+
+ const next: Record<string, number> = {}
+ for (const comment of fileComments()) {
+ const marker = findMarker(root, comment.selection)
+ if (!marker) continue
+ next[comment.id] = markerTop(el, marker)
+ }
+
+ setPositions(next)
+
+ const range = commenting()
+ if (!range) {
+ setDraftTop(undefined)
+ return
+ }
+
+ const marker = findMarker(root, range)
+ if (!marker) {
+ setDraftTop(undefined)
+ return
+ }
+
+ setDraftTop(markerTop(el, marker))
+ }
+
+ const scheduleComments = () => {
+ requestAnimationFrame(updateComments)
+ }
+
+ createEffect(() => {
+ fileComments()
+ scheduleComments()
+ })
+
+ createEffect(() => {
+ const range = commenting()
+ scheduleComments()
+ if (!range) return
+ setDraft("")
+ })
+
+ createEffect(() => {
+ const focus = props.comments.focus()
+ const p = path()
+ if (!focus || !p) return
+ if (focus.file !== p) return
+ if (props.activeTab() !== props.tab) return
+
+ const target = fileComments().find((comment) => comment.id === focus.id)
+ if (!target) return
+
+ setOpenedComment(target.id)
+ setCommenting(null)
+ props.file.setSelectedLines(p, target.selection)
+ requestAnimationFrame(() => props.comments.clearFocus())
+ })
+
+ const getCodeScroll = () => {
+ const el = scroll
+ if (!el) return []
+
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return []
+
+ const root = host.shadowRoot
+ if (!root) return []
+
+ return Array.from(root.querySelectorAll("[data-code]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
+ )
+ }
+
+ const queueScrollUpdate = (next: { x: number; y: number }) => {
+ pending = next
+ if (scrollFrame !== undefined) return
+
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
+
+ const out = pending
+ pending = undefined
+ if (!out) return
+
+ props.view().setScroll(props.tab, out)
+ })
+ }
+
+ const handleCodeScroll = (event: Event) => {
+ const el = scroll
+ if (!el) return
+
+ const target = event.currentTarget
+ if (!(target instanceof HTMLElement)) return
+
+ queueScrollUpdate({
+ x: target.scrollLeft,
+ y: el.scrollTop,
+ })
+ }
+
+ const syncCodeScroll = () => {
+ const next = getCodeScroll()
+ if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
+
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
+
+ codeScroll = next
+
+ for (const item of codeScroll) {
+ item.addEventListener("scroll", handleCodeScroll)
+ }
+ }
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = props.view()?.scroll(props.tab)
+ if (!s) return
+
+ syncCodeScroll()
+
+ if (codeScroll.length > 0) {
+ for (const item of codeScroll) {
+ if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+ }
+ }
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (codeScroll.length > 0) return
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ if (codeScroll.length === 0) syncCodeScroll()
+
+ queueScrollUpdate({
+ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ })
+ }
+
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => props.file.ready(),
+ (ready) => {
+ if (!ready) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => props.tabs().active() === props.tab,
+ (active) => {
+ if (!active) return
+ if (!state()?.loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
+
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
+ })
+
+ const renderCode = (source: string, wrapperClass: string) => (
+ <div
+ ref={(el) => {
+ wrap = el
+ scheduleComments()
+ }}
+ class={`relative overflow-hidden ${wrapperClass}`}
+ >
+ <Dynamic
+ component={props.codeComponent}
+ file={{
+ name: path() ?? "",
+ contents: source,
+ cacheKey: cacheKey(),
+ }}
+ enableLineSelection
+ selectedLines={selectedLines()}
+ commentedLines={commentedLines()}
+ onRendered={() => {
+ requestAnimationFrame(restoreScroll)
+ requestAnimationFrame(scheduleComments)
+ }}
+ onLineSelected={(range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ props.file.setSelectedLines(p, range)
+ if (!range) setCommenting(null)
+ }}
+ onLineSelectionEnd={(range: SelectedLineRange | null) => {
+ if (!range) {
+ setCommenting(null)
+ return
+ }
+
+ setOpenedComment(null)
+ setCommenting(range)
+ }}
+ overflow="scroll"
+ class="select-text"
+ />
+ <For each={fileComments()}>
+ {(comment) => (
+ <LineCommentView
+ id={comment.id}
+ top={positions()[comment.id]}
+ open={openedComment() === comment.id}
+ comment={comment.comment}
+ selection={commentLabel(comment.selection)}
+ onMouseEnter={() => {
+ const p = path()
+ if (!p) return
+ props.file.setSelectedLines(p, comment.selection)
+ }}
+ onClick={() => {
+ const p = path()
+ if (!p) return
+ setCommenting(null)
+ setOpenedComment((current) => (current === comment.id ? null : comment.id))
+ props.file.setSelectedLines(p, comment.selection)
+ }}
+ />
+ )}
+ </For>
+ <Show when={commenting()}>
+ {(range) => (
+ <Show when={draftTop() !== undefined}>
+ <LineCommentEditor
+ top={draftTop()}
+ value={draft()}
+ selection={commentLabel(range())}
+ onInput={(value) => setDraft(value)}
+ onCancel={() => setCommenting(null)}
+ onSubmit={(value) => {
+ const p = path()
+ if (!p) return
+ props.addCommentToContext({
+ file: p,
+ selection: range(),
+ comment: value,
+ origin: "file",
+ })
+ setCommenting(null)
+ }}
+ onPopoverFocusOut={(e: FocusEvent) => {
+ const current = e.currentTarget as HTMLDivElement
+ const target = e.relatedTarget
+ if (target instanceof Node && current.contains(target)) return
+
+ setTimeout(() => {
+ if (!document.activeElement || !current.contains(document.activeElement)) {
+ setCommenting(null)
+ }
+ }, 0)
+ }}
+ />
+ </Show>
+ )}
+ </Show>
+ </div>
+ )
+
+ return (
+ <Tabs.Content
+ value={props.tab}
+ class="mt-3 relative"
+ ref={(el: HTMLDivElement) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+ <Switch>
+ <Match when={state()?.loaded && isImage()}>
+ <div class="px-6 py-4 pb-40">
+ <img
+ src={imageDataUrl()}
+ alt={path()}
+ class="max-w-full"
+ onLoad={() => requestAnimationFrame(restoreScroll)}
+ />
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isSvg()}>
+ <div class="flex flex-col gap-4 px-6 py-4">
+ {renderCode(svgContent() ?? "", "")}
+ <Show when={svgPreviewUrl()}>
+ <div class="flex justify-center pb-40">
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+ </div>
+ </Show>
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isBinary()}>
+ <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+ <Mark class="w-14 opacity-10" />
+ <div class="flex flex-col gap-2 max-w-md">
+ <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
+ <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
+ </div>
+ </div>
+ </Match>
+ <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+ <Match when={state()?.loading}>
+ <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
+ </Match>
+ <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+ </Switch>
+ </Tabs.Content>
+ )
+}
diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts
new file mode 100644
index 000000000..b2af4bb83
--- /dev/null
+++ b/packages/app/src/pages/session/message-gesture.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture"
+
+describe("normalizeWheelDelta", () => {
+ test("converts line mode to px", () => {
+ expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120)
+ })
+
+ test("converts page mode to container height", () => {
+ expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600)
+ })
+
+ test("keeps pixel mode unchanged", () => {
+ expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16)
+ })
+})
+
+describe("shouldMarkBoundaryGesture", () => {
+ test("marks when nested scroller cannot scroll", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 20,
+ scrollTop: 0,
+ scrollHeight: 300,
+ clientHeight: 300,
+ }),
+ ).toBe(true)
+ })
+
+ test("marks when scrolling beyond top boundary", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: -40,
+ scrollTop: 10,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(true)
+ })
+
+ test("marks when scrolling beyond bottom boundary", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 50,
+ scrollTop: 580,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(true)
+ })
+
+ test("does not mark when nested scroller can consume movement", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 20,
+ scrollTop: 200,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts
new file mode 100644
index 000000000..731cb1bde
--- /dev/null
+++ b/packages/app/src/pages/session/message-gesture.ts
@@ -0,0 +1,21 @@
+export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => {
+ if (input.deltaMode === 1) return input.deltaY * 40
+ if (input.deltaMode === 2) return input.deltaY * input.rootHeight
+ return input.deltaY
+}
+
+export const shouldMarkBoundaryGesture = (input: {
+ delta: number
+ scrollTop: number
+ scrollHeight: number
+ clientHeight: number
+}) => {
+ const max = input.scrollHeight - input.clientHeight
+ if (max <= 1) return true
+ if (!input.delta) return false
+
+ if (input.delta < 0) return input.scrollTop + input.delta <= 0
+
+ const remaining = max - input.scrollTop
+ return input.delta > remaining
+}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
new file mode 100644
index 000000000..f536c7061
--- /dev/null
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -0,0 +1,348 @@
+import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+
+export function MessageTimeline(props: {
+ mobileChanges: boolean
+ mobileFallback: JSX.Element
+ scroll: { overflow: boolean; bottom: boolean }
+ onResumeScroll: () => void
+ setScrollRef: (el: HTMLDivElement | undefined) => void
+ onScheduleScrollState: (el: HTMLDivElement) => void
+ onAutoScrollHandleScroll: () => void
+ onMarkScrollGesture: (target?: EventTarget | null) => void
+ hasScrollGesture: () => boolean
+ isDesktop: boolean
+ onScrollSpyScroll: () => void
+ onAutoScrollInteraction: (event: MouseEvent) => void
+ showHeader: boolean
+ centered: boolean
+ title?: string
+ parentID?: string
+ openTitleEditor: () => void
+ closeTitleEditor: () => void
+ saveTitleEditor: () => void | Promise<void>
+ titleRef: (el: HTMLInputElement) => void
+ titleState: {
+ draft: string
+ editing: boolean
+ saving: boolean
+ menuOpen: boolean
+ pendingRename: boolean
+ }
+ onTitleDraft: (value: string) => void
+ onTitleMenuOpen: (open: boolean) => void
+ onTitlePendingRename: (value: boolean) => void
+ onNavigateParent: () => void
+ sessionID: string
+ onArchiveSession: (sessionID: string) => void
+ onDeleteSession: (sessionID: string) => void
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+ setContentRef: (el: HTMLDivElement) => void
+ turnStart: number
+ onRenderEarlier: () => void
+ historyMore: boolean
+ historyLoading: boolean
+ onLoadEarlier: () => void
+ renderedUserMessages: UserMessage[]
+ anchor: (id: string) => string
+ onRegisterMessage: (el: HTMLDivElement, id: string) => void
+ onUnregisterMessage: (id: string) => void
+ onFirstTurnMount?: () => void
+ lastUserMessageID?: string
+ expanded: Record<string, boolean>
+ onToggleExpanded: (id: string) => void
+}) {
+ let touchGesture: number | undefined
+
+ return (
+ <Show
+ when={!props.mobileChanges}
+ fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
+ >
+ <div class="relative w-full h-full min-w-0">
+ <div
+ class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
+ classList={{
+ "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
+ "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
+ }}
+ >
+ <button
+ class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
+ onClick={props.onResumeScroll}
+ >
+ <Icon name="arrow-down-to-line" />
+ </button>
+ </div>
+ <div
+ ref={props.setScrollRef}
+ onWheel={(e) => {
+ const root = e.currentTarget
+ const target = e.target instanceof Element ? e.target : undefined
+ const nested = target?.closest("[data-scrollable]")
+ if (!nested || nested === root) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (!(nested instanceof HTMLElement)) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ const delta = normalizeWheelDelta({
+ deltaY: e.deltaY,
+ deltaMode: e.deltaMode,
+ rootHeight: root.clientHeight,
+ })
+ if (!delta) return
+
+ if (
+ shouldMarkBoundaryGesture({
+ delta,
+ scrollTop: nested.scrollTop,
+ scrollHeight: nested.scrollHeight,
+ clientHeight: nested.clientHeight,
+ })
+ ) {
+ props.onMarkScrollGesture(root)
+ }
+ }}
+ onTouchStart={(e) => {
+ touchGesture = e.touches[0]?.clientY
+ }}
+ onTouchMove={(e) => {
+ const next = e.touches[0]?.clientY
+ const prev = touchGesture
+ touchGesture = next
+ if (next === undefined || prev === undefined) return
+
+ const delta = prev - next
+ if (!delta) return
+
+ const root = e.currentTarget
+ const target = e.target instanceof Element ? e.target : undefined
+ const nested = target?.closest("[data-scrollable]")
+ if (!nested || nested === root) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (!(nested instanceof HTMLElement)) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (
+ shouldMarkBoundaryGesture({
+ delta,
+ scrollTop: nested.scrollTop,
+ scrollHeight: nested.scrollHeight,
+ clientHeight: nested.clientHeight,
+ })
+ ) {
+ props.onMarkScrollGesture(root)
+ }
+ }}
+ onTouchEnd={() => {
+ touchGesture = undefined
+ }}
+ onTouchCancel={() => {
+ touchGesture = undefined
+ }}
+ onPointerDown={(e) => {
+ if (e.target !== e.currentTarget) return
+ props.onMarkScrollGesture(e.currentTarget)
+ }}
+ onScroll={(e) => {
+ props.onScheduleScrollState(e.currentTarget)
+ if (!props.hasScrollGesture()) return
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(e.currentTarget)
+ if (props.isDesktop) props.onScrollSpyScroll()
+ }}
+ onClick={props.onAutoScrollInteraction}
+ class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+ style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
+ >
+ <Show when={props.showHeader}>
+ <div
+ classList={{
+ "sticky top-0 z-30 bg-background-stronger": true,
+ "w-full": true,
+ "px-4 md:px-6": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <div class="h-10 w-full flex items-center justify-between gap-2">
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <Show when={props.parentID}>
+ <IconButton
+ tabIndex={-1}
+ icon="arrow-left"
+ variant="ghost"
+ onClick={props.onNavigateParent}
+ aria-label={props.t("common.goBack")}
+ />
+ </Show>
+ <Show when={props.title || props.titleState.editing}>
+ <Show
+ when={props.titleState.editing}
+ fallback={
+ <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
+ {props.title}
+ </h1>
+ }
+ >
+ <InlineInput
+ ref={props.titleRef}
+ value={props.titleState.draft}
+ disabled={props.titleState.saving}
+ class="text-16-medium text-text-strong grow-1 min-w-0"
+ onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void props.saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ props.closeTitleEditor()
+ }
+ }}
+ onBlur={props.closeTitleEditor}
+ />
+ </Show>
+ </Show>
+ </div>
+ <Show when={props.sessionID}>
+ {(id) => (
+ <div class="shrink-0 flex items-center">
+ <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
+ <Tooltip value={props.t("common.moreOptions")} placement="top">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ aria-label={props.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!props.titleState.pendingRename) return
+ event.preventDefault()
+ props.onTitlePendingRename(false)
+ props.openTitleEditor()
+ }}
+ >
+ <DropdownMenu.Item
+ onSelect={() => {
+ props.onTitlePendingRename(true)
+ props.onTitleMenuOpen(false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
+ <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
+ <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ )}
+ </Show>
+ </div>
+ </div>
+ </Show>
+
+ <div
+ ref={props.setContentRef}
+ role="log"
+ class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+ classList={{
+ "w-full": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ "mt-0.5": props.centered,
+ "mt-0": !props.centered,
+ }}
+ >
+ <Show when={props.turnStart > 0}>
+ <div class="w-full flex justify-center">
+ <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
+ {props.t("session.messages.renderEarlier")}
+ </Button>
+ </div>
+ </Show>
+ <Show when={props.historyMore}>
+ <div class="w-full flex justify-center">
+ <Button
+ variant="ghost"
+ size="large"
+ class="text-12-medium opacity-50"
+ disabled={props.historyLoading}
+ onClick={props.onLoadEarlier}
+ >
+ {props.historyLoading
+ ? props.t("session.messages.loadingEarlier")
+ : props.t("session.messages.loadEarlier")}
+ </Button>
+ </div>
+ </Show>
+ <For each={props.renderedUserMessages}>
+ {(message) => {
+ if (import.meta.env.DEV && props.onFirstTurnMount) {
+ onMount(() => props.onFirstTurnMount?.())
+ }
+
+ return (
+ <div
+ id={props.anchor(message.id)}
+ data-message-id={message.id}
+ ref={(el) => {
+ props.onRegisterMessage(el, message.id)
+ onCleanup(() => props.onUnregisterMessage(message.id))
+ }}
+ classList={{
+ "min-w-0 w-full max-w-full": true,
+ "md:max-w-200 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <SessionTurn
+ sessionID={props.sessionID}
+ messageID={message.id}
+ lastUserMessageID={props.lastUserMessageID}
+ stepsExpanded={props.expanded[message.id] ?? false}
+ onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
+ classes={{
+ root: "min-w-0 w-full relative",
+ content: "flex flex-col justify-between !overflow-visible",
+ container: "w-full px-4 md:px-6",
+ }}
+ />
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
new file mode 100644
index 000000000..a4232dd74
--- /dev/null
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -0,0 +1,158 @@
+import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import type { SelectedLineRange } from "@/context/file"
+import { useSDK } from "@/context/sdk"
+import { useLayout } from "@/context/layout"
+import type { LineComment } from "@/context/comments"
+
+export type DiffStyle = "unified" | "split"
+
+export interface SessionReviewTabProps {
+ title?: JSX.Element
+ empty?: JSX.Element
+ diffs: () => FileDiff[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ diffStyle: DiffStyle
+ onDiffStyleChange?: (style: DiffStyle) => void
+ onViewFile?: (file: string) => void
+ onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+ comments?: LineComment[]
+ focusedComment?: { file: string; id: string } | null
+ onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
+ focusedFile?: string
+ onScrollRef?: (el: HTMLDivElement) => void
+ classes?: {
+ root?: string
+ header?: string
+ container?: string
+ }
+}
+
+export function StickyAddButton(props: { children: JSX.Element }) {
+ const [stuck, setStuck] = createSignal(false)
+ let button: HTMLDivElement | undefined
+
+ createEffect(() => {
+ const node = button
+ if (!node) return
+
+ const scroll = node.parentElement
+ if (!scroll) return
+
+ const handler = () => {
+ const rect = node.getBoundingClientRect()
+ const scrollRect = scroll.getBoundingClientRect()
+ setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
+ }
+
+ scroll.addEventListener("scroll", handler, { passive: true })
+ const observer = new ResizeObserver(handler)
+ observer.observe(scroll)
+ handler()
+ onCleanup(() => {
+ scroll.removeEventListener("scroll", handler)
+ observer.disconnect()
+ })
+ })
+
+ return (
+ <div
+ ref={button}
+ class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
+ classList={{ "border-l": stuck() }}
+ >
+ {props.children}
+ </div>
+ )
+}
+
+export function SessionReviewTab(props: SessionReviewTabProps) {
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const sdk = useSDK()
+
+ const readFile = async (path: string) => {
+ return sdk.client.file
+ .read({ path })
+ .then((x) => x.data)
+ .catch(() => undefined)
+ }
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = props.view().scroll("review")
+ if (!s) return
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
+ if (frame !== undefined) return
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+
+ const next = pending
+ pending = undefined
+ if (!next) return
+
+ props.view().setScroll("review", next)
+ })
+ }
+
+ createEffect(
+ on(
+ () => props.diffs().length,
+ () => {
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
+ return (
+ <SessionReview
+ title={props.title}
+ empty={props.empty}
+ scrollRef={(el) => {
+ scroll = el
+ props.onScrollRef?.(el)
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ onDiffRendered={() => requestAnimationFrame(restoreScroll)}
+ open={props.view().review.open()}
+ onOpenChange={props.view().review.setOpen}
+ classes={{
+ root: props.classes?.root ?? "pb-40",
+ header: props.classes?.header ?? "px-6",
+ container: props.classes?.container ?? "px-6",
+ }}
+ diffs={props.diffs()}
+ diffStyle={props.diffStyle}
+ onDiffStyleChange={props.onDiffStyleChange}
+ onViewFile={props.onViewFile}
+ focusedFile={props.focusedFile}
+ readFile={readFile}
+ onLineComment={props.onLineComment}
+ comments={props.comments}
+ focusedComment={props.focusedComment}
+ onFocusedCommentChange={props.onFocusedCommentChange}
+ />
+ )
+}
diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts
new file mode 100644
index 000000000..b71a7b768
--- /dev/null
+++ b/packages/app/src/pages/session/session-command-helpers.ts
@@ -0,0 +1,10 @@
+export const canAddSelectionContext = (input: {
+ active?: string
+ pathFromTab: (tab: string) => string | undefined
+ selectedLines: (path: string) => unknown
+}) => {
+ if (!input.active) return false
+ const path = input.pathFromTab(input.active)
+ if (!path) return false
+ return input.selectedLines(path) != null
+}
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
new file mode 100644
index 000000000..41f058231
--- /dev/null
+++ b/packages/app/src/pages/session/session-mobile-tabs.tsx
@@ -0,0 +1,36 @@
+import { Match, Show, Switch } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+
+export function SessionMobileTabs(props: {
+ open: boolean
+ hasReview: boolean
+ reviewCount: number
+ onSession: () => void
+ onChanges: () => void
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) {
+ return (
+ <Show when={props.open}>
+ <Tabs class="h-auto">
+ <Tabs.List>
+ <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
+ {props.t("session.tab.session")}
+ </Tabs.Trigger>
+ <Tabs.Trigger
+ value="changes"
+ class="w-1/2 !border-r-0"
+ classes={{ button: "w-full" }}
+ onClick={props.onChanges}
+ >
+ <Switch>
+ <Match when={props.hasReview}>
+ {props.t("session.review.filesChanged", { count: props.reviewCount })}
+ </Match>
+ <Match when={true}>{props.t("session.review.change.other")}</Match>
+ </Switch>
+ </Tabs.Trigger>
+ </Tabs.List>
+ </Tabs>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts
new file mode 100644
index 000000000..b3a9945d6
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-dock.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from "bun:test"
+import { questionSubtitle } from "./session-prompt-helpers"
+
+describe("questionSubtitle", () => {
+ const t = (key: string) => {
+ if (key === "ui.common.question.one") return "question"
+ if (key === "ui.common.question.other") return "questions"
+ return key
+ }
+
+ test("returns empty for zero", () => {
+ expect(questionSubtitle(0, t)).toBe("")
+ })
+
+ test("uses singular label", () => {
+ expect(questionSubtitle(1, t)).toBe("1 question")
+ })
+
+ test("uses plural label", () => {
+ expect(questionSubtitle(3, t)).toBe("3 questions")
+ })
+})
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
new file mode 100644
index 000000000..697957027
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -0,0 +1,137 @@
+import { For, Show, type ComponentProps } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { BasicTool } from "@opencode-ai/ui/basic-tool"
+import { PromptInput } from "@/components/prompt-input"
+import { QuestionDock } from "@/components/question-dock"
+import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
+
+const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
+
+export function SessionPromptDock(props: {
+ centered: boolean
+ questionRequest: () => { questions: unknown[] } | undefined
+ permissionRequest: () => { patterns: string[]; permission: string } | undefined
+ blocked: boolean
+ promptReady: boolean
+ handoffPrompt?: string
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+ responding: boolean
+ onDecide: (response: "once" | "always" | "reject") => void
+ inputRef: (el: HTMLDivElement) => void
+ newSessionWorktree: string
+ onNewSessionWorktreeReset: () => void
+ onSubmit: () => void
+ setPromptDockRef: (el: HTMLDivElement) => void
+}) {
+ return (
+ <div
+ ref={props.setPromptDockRef}
+ class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
+ >
+ <div
+ classList={{
+ "w-full px-4 pointer-events-auto": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <Show when={props.questionRequest()} keyed>
+ {(req) => {
+ const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
+ return (
+ <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
+ <BasicTool
+ icon="bubble-5"
+ locked
+ defaultOpen
+ trigger={{
+ title: props.t("ui.tool.questions"),
+ subtitle,
+ }}
+ />
+ <QuestionDock request={questionDockRequest(req)} />
+ </div>
+ )
+ }}
+ </Show>
+
+ <Show when={props.permissionRequest()} keyed>
+ {(perm) => (
+ <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
+ <BasicTool
+ icon="checklist"
+ locked
+ defaultOpen
+ trigger={{
+ title: props.t("notification.permission.title"),
+ subtitle:
+ perm.permission === "doom_loop"
+ ? props.t("settings.permissions.tool.doom_loop.title")
+ : perm.permission,
+ }}
+ >
+ <Show when={perm.patterns.length > 0}>
+ <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
+ <For each={perm.patterns}>
+ {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+ </For>
+ </div>
+ </Show>
+ <Show when={perm.permission === "doom_loop"}>
+ <div class="text-12-regular text-text-weak pb-2 px-3">
+ {props.t("settings.permissions.tool.doom_loop.description")}
+ </div>
+ </Show>
+ </BasicTool>
+ <div data-component="permission-prompt">
+ <div data-slot="permission-actions">
+ <Button
+ variant="ghost"
+ size="small"
+ onClick={() => props.onDecide("reject")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.deny")}
+ </Button>
+ <Button
+ variant="secondary"
+ size="small"
+ onClick={() => props.onDecide("always")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.allowAlways")}
+ </Button>
+ <Button
+ variant="primary"
+ size="small"
+ onClick={() => props.onDecide("once")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.allowOnce")}
+ </Button>
+ </div>
+ </div>
+ </div>
+ )}
+ </Show>
+
+ <Show when={!props.blocked}>
+ <Show
+ when={props.promptReady}
+ fallback={
+ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+ {props.handoffPrompt || props.t("prompt.loading")}
+ </div>
+ }
+ >
+ <PromptInput
+ ref={props.inputRef}
+ newSessionWorktree={props.newSessionWorktree}
+ onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+ onSubmit={props.onSubmit}
+ />
+ </Show>
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts
new file mode 100644
index 000000000..ac3234c93
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-helpers.ts
@@ -0,0 +1,4 @@
+export const questionSubtitle = (count: number, t: (key: string) => string) => {
+ if (count === 0) return ""
+ return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
+}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
new file mode 100644
index 000000000..573680dec
--- /dev/null
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -0,0 +1,306 @@
+import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Mark } from "@opencode-ai/ui/logo"
+import FileTree from "@/components/file-tree"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import { StickyAddButton } from "@/pages/session/review-tab"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSync } from "@/context/sync"
+
+export function SessionSidePanel(props: {
+ open: boolean
+ language: ReturnType<typeof useLanguage>
+ layout: ReturnType<typeof useLayout>
+ command: ReturnType<typeof useCommand>
+ dialog: ReturnType<typeof useDialog>
+ file: ReturnType<typeof useFile>
+ comments: ReturnType<typeof useComments>
+ sync: ReturnType<typeof useSync>
+ hasReview: boolean
+ reviewCount: number
+ reviewTab: boolean
+ contextOpen: () => boolean
+ openedTabs: () => string[]
+ activeTab: () => string
+ activeFileTab: () => string | undefined
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ openTab: (value: string) => void
+ showAllFiles: () => void
+ reviewPanel: () => JSX.Element
+ messages: () => unknown[]
+ visibleUserMessages: () => unknown[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => unknown
+ handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+ codeComponent: NonNullable<ValidComponent>
+ addCommentToContext: (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => void
+ activeDraggable: () => string | undefined
+ onDragStart: (event: unknown) => void
+ onDragEnd: () => void
+ onDragOver: (event: DragEvent) => void
+ fileTreeTab: () => "changes" | "all"
+ setFileTreeTabValue: (value: string) => void
+ diffsReady: boolean
+ diffFiles: string[]
+ kinds: Map<string, "add" | "del" | "mix">
+ activeDiff?: string
+ focusReviewDiff: (path: string) => void
+}) {
+ return (
+ <Show when={props.open}>
+ <aside
+ id="review-panel"
+ aria-label={props.language.t("session.panel.reviewAndFiles")}
+ class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
+ >
+ <div class="flex-1 min-w-0 h-full">
+ <Show
+ when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
+ fallback={
+ <DragDropProvider
+ onDragStart={props.onDragStart}
+ onDragEnd={props.onDragEnd}
+ onDragOver={props.onDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={props.activeTab()} onChange={props.openTab}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List
+ ref={(el: HTMLDivElement) => {
+ const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
+ onCleanup(stop)
+ }}
+ >
+ <Show when={props.reviewTab}>
+ <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
+ <div class="flex items-center gap-1.5">
+ <div>{props.language.t("session.tab.review")}</div>
+ <Show when={props.hasReview}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {props.reviewCount}
+ </div>
+ </Show>
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <Show when={props.contextOpen()}>
+ <Tabs.Trigger
+ value="context"
+ closeButton={
+ <Tooltip value={props.language.t("common.closeTab")} placement="bottom">
+ <IconButton
+ icon="close-small"
+ variant="ghost"
+ class="h-5 w-5"
+ onClick={() => props.tabs().close("context")}
+ aria-label={props.language.t("common.closeTab")}
+ />
+ </Tooltip>
+ }
+ hideCloseButton
+ onMiddleClick={() => props.tabs().close("context")}
+ >
+ <div class="flex items-center gap-2">
+ <SessionContextUsage variant="indicator" />
+ <div>{props.language.t("session.tab.context")}</div>
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <SortableProvider ids={props.openedTabs()}>
+ <For each={props.openedTabs()}>
+ {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
+ </For>
+ </SortableProvider>
+ <StickyAddButton>
+ <TooltipKeybind
+ title={props.language.t("command.file.open")}
+ keybind={props.command.keybind("file.open")}
+ class="flex items-center"
+ >
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() =>
+ props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
+ }
+ aria-label={props.language.t("command.file.open")}
+ />
+ </TooltipKeybind>
+ </StickyAddButton>
+ </Tabs.List>
+ </div>
+
+ <Show when={props.reviewTab}>
+ <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
+ </Tabs.Content>
+ </Show>
+
+ <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "empty"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+ <Mark class="w-14 opacity-10" />
+ <div class="text-14-regular text-text-weak max-w-56">
+ {props.language.t("session.files.selectToOpen")}
+ </div>
+ </div>
+ </div>
+ </Show>
+ </Tabs.Content>
+
+ <Show when={props.contextOpen()}>
+ <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "context"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <SessionContextTab
+ messages={props.messages as never}
+ visibleUserMessages={props.visibleUserMessages as never}
+ view={props.view as never}
+ info={props.info as never}
+ />
+ </div>
+ </Show>
+ </Tabs.Content>
+ </Show>
+
+ <Show when={props.activeFileTab()} keyed>
+ {(tab) => (
+ <FileTabContent
+ tab={tab}
+ activeTab={props.activeTab}
+ tabs={props.tabs}
+ view={props.view}
+ handoffFiles={props.handoffFiles}
+ file={props.file}
+ comments={props.comments}
+ language={props.language}
+ codeComponent={props.codeComponent}
+ addCommentToContext={props.addCommentToContext}
+ />
+ )}
+ </Show>
+ </Tabs>
+ <DragOverlay>
+ <Show when={props.activeDraggable()}>
+ {(tab) => {
+ const path = createMemo(() => props.file.pathFromTab(tab()))
+ return (
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ }
+ >
+ {props.reviewPanel()}
+ </Show>
+ </div>
+
+ <Show when={props.layout.fileTree.opened()}>
+ <div
+ id="file-tree-panel"
+ class="relative shrink-0 h-full"
+ style={{ width: `${props.layout.fileTree.width()}px` }}
+ >
+ <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
+ <Tabs
+ variant="pill"
+ value={props.fileTreeTab()}
+ onChange={props.setFileTreeTabValue}
+ class="h-full"
+ data-scope="filetree"
+ >
+ <Tabs.List>
+ <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
+ {props.reviewCount}{" "}
+ {props.language.t(
+ props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
+ )}
+ </Tabs.Trigger>
+ <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
+ {props.language.t("session.files.all")}
+ </Tabs.Trigger>
+ </Tabs.List>
+ <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
+ <Switch>
+ <Match when={props.hasReview}>
+ <Show
+ when={props.diffsReady}
+ fallback={
+ <div class="px-2 py-2 text-12-regular text-text-weak">
+ {props.language.t("common.loading")}
+ {props.language.t("common.loading.ellipsis")}
+ </div>
+ }
+ >
+ <FileTree
+ path=""
+ allowed={props.diffFiles}
+ kinds={props.kinds}
+ draggable={false}
+ active={props.activeDiff}
+ onFileClick={(node) => props.focusReviewDiff(node.path)}
+ />
+ </Show>
+ </Match>
+ <Match when={true}>
+ <div class="mt-8 text-center text-12-regular text-text-weak">
+ {props.language.t("session.review.noChanges")}
+ </div>
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ <Tabs.Content value="all" class="bg-background-base px-3 py-0">
+ <FileTree
+ path=""
+ modified={props.diffFiles}
+ kinds={props.kinds}
+ onFileClick={(node) => props.openTab(props.file.tab(node.path))}
+ />
+ </Tabs.Content>
+ </Tabs>
+ </div>
+ <ResizeHandle
+ direction="horizontal"
+ edge="start"
+ size={props.layout.fileTree.width()}
+ min={200}
+ max={480}
+ collapseThreshold={160}
+ onResize={props.layout.fileTree.resize}
+ onCollapse={props.layout.fileTree.close}
+ />
+ </div>
+ </Show>
+ </aside>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/terminal-label.ts b/packages/app/src/pages/session/terminal-label.ts
new file mode 100644
index 000000000..6d336769b
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-label.ts
@@ -0,0 +1,16 @@
+export const terminalTabLabel = (input: {
+ title?: string
+ titleNumber?: number
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) => {
+ const title = input.title ?? ""
+ const number = input.titleNumber ?? 0
+ const match = title.match(/^Terminal (\d+)$/)
+ const parsed = match ? Number(match[1]) : undefined
+ const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
+
+ if (title && !isDefaultTitle) return title
+ if (number > 0) return input.t("terminal.title.numbered", { number })
+ if (title) return title
+ return input.t("terminal.title")
+}
diff --git a/packages/app/src/pages/session/terminal-panel.test.ts b/packages/app/src/pages/session/terminal-panel.test.ts
new file mode 100644
index 000000000..43eeec32f
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-panel.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { terminalTabLabel } from "./terminal-label"
+
+const t = (key: string, vars?: Record<string, string | number | boolean>) => {
+ if (key === "terminal.title.numbered") return `Terminal ${vars?.number}`
+ if (key === "terminal.title") return "Terminal"
+ return key
+}
+
+describe("terminalTabLabel", () => {
+ test("returns custom title unchanged", () => {
+ const label = terminalTabLabel({ title: "server", titleNumber: 3, t })
+ expect(label).toBe("server")
+ })
+
+ test("normalizes default numbered title", () => {
+ const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t })
+ expect(label).toBe("Terminal 2")
+ })
+
+ test("falls back to generic title", () => {
+ const label = terminalTabLabel({ title: "", titleNumber: 0, t })
+ expect(label).toBe("Terminal")
+ })
+})
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
new file mode 100644
index 000000000..09095d689
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -0,0 +1,169 @@
+import { createMemo, For, Show } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import { SortableTerminalTab } from "@/components/session"
+import { Terminal } from "@/components/terminal"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+
+export function TerminalPanel(props: {
+ open: boolean
+ height: number
+ resize: (value: number) => void
+ close: () => void
+ terminal: ReturnType<typeof useTerminal>
+ language: ReturnType<typeof useLanguage>
+ command: ReturnType<typeof useCommand>
+ handoff: () => string[]
+ activeTerminalDraggable: () => string | undefined
+ handleTerminalDragStart: (event: unknown) => void
+ handleTerminalDragOver: (event: DragEvent) => void
+ handleTerminalDragEnd: () => void
+ onCloseTab: () => void
+}) {
+ return (
+ <Show when={props.open}>
+ <div
+ id="terminal-panel"
+ role="region"
+ aria-label={props.language.t("terminal.title")}
+ class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+ style={{ height: `${props.height}px` }}
+ >
+ <ResizeHandle
+ direction="vertical"
+ size={props.height}
+ min={100}
+ max={window.innerHeight * 0.6}
+ collapseThreshold={50}
+ onResize={props.resize}
+ onCollapse={props.close}
+ />
+ <Show
+ when={props.terminal.ready()}
+ fallback={
+ <div class="flex flex-col h-full pointer-events-none">
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
+ <For each={props.handoff()}>
+ {(title) => (
+ <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+ {title}
+ </div>
+ )}
+ </For>
+ <div class="flex-1" />
+ <div class="text-text-weak pr-2">
+ {props.language.t("common.loading")}
+ {props.language.t("common.loading.ellipsis")}
+ </div>
+ </div>
+ <div class="flex-1 flex items-center justify-center text-text-weak">
+ {props.language.t("terminal.loading")}
+ </div>
+ </div>
+ }
+ >
+ <DragDropProvider
+ onDragStart={props.handleTerminalDragStart}
+ onDragEnd={props.handleTerminalDragEnd}
+ onDragOver={props.handleTerminalDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <div class="flex flex-col h-full">
+ <Tabs
+ variant="alt"
+ value={props.terminal.active()}
+ onChange={(id) => props.terminal.open(id)}
+ class="!h-auto !flex-none"
+ >
+ <Tabs.List class="h-10">
+ <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}>
+ <For each={props.terminal.all()}>
+ {(pty) => (
+ <SortableTerminalTab
+ terminal={pty}
+ onClose={() => {
+ props.close()
+ props.onCloseTab()
+ }}
+ />
+ )}
+ </For>
+ </SortableProvider>
+ <div class="h-full flex items-center justify-center">
+ <TooltipKeybind
+ title={props.language.t("command.terminal.new")}
+ keybind={props.command.keybind("terminal.new")}
+ class="flex items-center"
+ >
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={props.terminal.new}
+ aria-label={props.language.t("command.terminal.new")}
+ />
+ </TooltipKeybind>
+ </div>
+ </Tabs.List>
+ </Tabs>
+ <div class="flex-1 min-h-0 relative">
+ <For each={props.terminal.all()}>
+ {(pty) => (
+ <div
+ id={`terminal-wrapper-${pty.id}`}
+ class="absolute inset-0"
+ style={{
+ display: props.terminal.active() === pty.id ? "block" : "none",
+ }}
+ >
+ <Show when={pty.id} keyed>
+ <Terminal
+ pty={pty}
+ onCleanup={props.terminal.update}
+ onConnectError={() => props.terminal.clone(pty.id)}
+ />
+ </Show>
+ </div>
+ )}
+ </For>
+ </div>
+ </div>
+ <DragOverlay>
+ <Show when={props.activeTerminalDraggable()}>
+ {(draggedId) => {
+ const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+ <Show when={pty()}>
+ {(t) => (
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+ {terminalTabLabel({
+ title: t().title,
+ titleNumber: t().titleNumber,
+ t: props.language.t as (
+ key: string,
+ vars?: Record<string, string | number | boolean>,
+ ) => string,
+ })}
+ </div>
+ )}
+ </Show>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ </Show>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts
new file mode 100644
index 000000000..ada1871e1
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-commands.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { canAddSelectionContext } from "./session-command-helpers"
+
+describe("canAddSelectionContext", () => {
+ test("returns false without active tab", () => {
+ expect(
+ canAddSelectionContext({
+ active: undefined,
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => ({ start: 1, end: 1 }),
+ }),
+ ).toBe(false)
+ })
+
+ test("returns false when active tab is not a file", () => {
+ expect(
+ canAddSelectionContext({
+ active: "context",
+ pathFromTab: () => undefined,
+ selectedLines: () => ({ start: 1, end: 1 }),
+ }),
+ ).toBe(false)
+ })
+
+ test("returns false without selected lines", () => {
+ expect(
+ canAddSelectionContext({
+ active: "file://src/a.ts",
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => null,
+ }),
+ ).toBe(false)
+ })
+
+ test("returns true when file and selection exist", () => {
+ expect(
+ canAddSelectionContext({
+ active: "file://src/a.ts",
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => ({ start: 1, end: 2 }),
+ }),
+ ).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
new file mode 100644
index 000000000..ae845a657
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -0,0 +1,439 @@
+import { createMemo } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import { usePermission } from "@/context/permission"
+import { usePrompt } from "@/context/prompt"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+import { DialogFork } from "@/components/dialog-fork"
+import { showToast } from "@opencode-ai/ui/toast"
+import { findLast } from "@opencode-ai/util/array"
+import { extractPromptFromParts } from "@/utils/prompt"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+import { combineCommandSections } from "@/pages/session/helpers"
+import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
+
+export const useSessionCommands = (input: {
+ command: ReturnType<typeof useCommand>
+ dialog: ReturnType<typeof useDialog>
+ file: ReturnType<typeof useFile>
+ language: ReturnType<typeof useLanguage>
+ local: ReturnType<typeof useLocal>
+ permission: ReturnType<typeof usePermission>
+ prompt: ReturnType<typeof usePrompt>
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ terminal: ReturnType<typeof useTerminal>
+ layout: ReturnType<typeof useLayout>
+ params: ReturnType<typeof useParams>
+ navigate: ReturnType<typeof useNavigate>
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
+ status: () => { type: string }
+ userMessages: () => UserMessage[]
+ visibleUserMessages: () => UserMessage[]
+ activeMessage: () => UserMessage | undefined
+ showAllFiles: () => void
+ navigateMessageByOffset: (offset: number) => void
+ setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
+ setActiveMessage: (message: UserMessage | undefined) => void
+ addSelectionToContext: (path: string, selection: FileSelection) => void
+}) => {
+ const sessionCommands = createMemo(() => [
+ {
+ id: "session.new",
+ title: input.language.t("command.session.new"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+shift+s",
+ slash: "new",
+ onSelect: () => input.navigate(`/${input.params.dir}/session`),
+ },
+ ])
+
+ const fileCommands = createMemo(() => [
+ {
+ id: "file.open",
+ title: input.language.t("command.file.open"),
+ description: input.language.t("palette.search.placeholder"),
+ category: input.language.t("command.category.file"),
+ keybind: "mod+p",
+ slash: "open",
+ onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
+ },
+ {
+ id: "tab.close",
+ title: input.language.t("command.tab.close"),
+ category: input.language.t("command.category.file"),
+ keybind: "mod+w",
+ disabled: !input.tabs().active(),
+ onSelect: () => {
+ const active = input.tabs().active()
+ if (!active) return
+ input.tabs().close(active)
+ },
+ },
+ ])
+
+ const contextCommands = createMemo(() => [
+ {
+ id: "context.addSelection",
+ title: input.language.t("command.context.addSelection"),
+ description: input.language.t("command.context.addSelection.description"),
+ category: input.language.t("command.category.context"),
+ keybind: "mod+shift+l",
+ disabled: !canAddSelectionContext({
+ active: input.tabs().active(),
+ pathFromTab: input.file.pathFromTab,
+ selectedLines: input.file.selectedLines,
+ }),
+ onSelect: () => {
+ const active = input.tabs().active()
+ if (!active) return
+ const path = input.file.pathFromTab(active)
+ if (!path) return
+
+ const range = input.file.selectedLines(path)
+ if (!range) {
+ showToast({
+ title: input.language.t("toast.context.noLineSelection.title"),
+ description: input.language.t("toast.context.noLineSelection.description"),
+ })
+ return
+ }
+
+ input.addSelectionToContext(path, selectionFromLines(range))
+ },
+ },
+ ])
+
+ const viewCommands = createMemo(() => [
+ {
+ id: "terminal.toggle",
+ title: input.language.t("command.terminal.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ keybind: "ctrl+`",
+ slash: "terminal",
+ onSelect: () => input.view().terminal.toggle(),
+ },
+ {
+ id: "review.toggle",
+ title: input.language.t("command.review.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ keybind: "mod+shift+r",
+ onSelect: () => input.view().reviewPanel.toggle(),
+ },
+ {
+ id: "fileTree.toggle",
+ title: input.language.t("command.fileTree.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ onSelect: () => {
+ const opening = !input.layout.fileTree.opened()
+ if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
+ input.layout.fileTree.toggle()
+ },
+ },
+ {
+ id: "terminal.new",
+ title: input.language.t("command.terminal.new"),
+ description: input.language.t("command.terminal.new.description"),
+ category: input.language.t("command.category.terminal"),
+ keybind: "ctrl+alt+t",
+ onSelect: () => {
+ if (input.terminal.all().length > 0) input.terminal.new()
+ input.view().terminal.open()
+ },
+ },
+ {
+ id: "steps.toggle",
+ title: input.language.t("command.steps.toggle"),
+ description: input.language.t("command.steps.toggle.description"),
+ category: input.language.t("command.category.view"),
+ keybind: "mod+e",
+ slash: "steps",
+ disabled: !input.params.id,
+ onSelect: () => {
+ const msg = input.activeMessage()
+ if (!msg) return
+ input.setExpanded(msg.id, (open: boolean | undefined) => !open)
+ },
+ },
+ ])
+
+ const messageCommands = createMemo(() => [
+ {
+ id: "message.previous",
+ title: input.language.t("command.message.previous"),
+ description: input.language.t("command.message.previous.description"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+arrowup",
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(-1),
+ },
+ {
+ id: "message.next",
+ title: input.language.t("command.message.next"),
+ description: input.language.t("command.message.next.description"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+arrowdown",
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(1),
+ },
+ ])
+
+ const agentCommands = createMemo(() => [
+ {
+ id: "model.choose",
+ title: input.language.t("command.model.choose"),
+ description: input.language.t("command.model.choose.description"),
+ category: input.language.t("command.category.model"),
+ keybind: "mod+'",
+ slash: "model",
+ onSelect: () => input.dialog.show(() => <DialogSelectModel />),
+ },
+ {
+ id: "mcp.toggle",
+ title: input.language.t("command.mcp.toggle"),
+ description: input.language.t("command.mcp.toggle.description"),
+ category: input.language.t("command.category.mcp"),
+ keybind: "mod+;",
+ slash: "mcp",
+ onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
+ },
+ {
+ id: "agent.cycle",
+ title: input.language.t("command.agent.cycle"),
+ description: input.language.t("command.agent.cycle.description"),
+ category: input.language.t("command.category.agent"),
+ keybind: "mod+.",
+ slash: "agent",
+ onSelect: () => input.local.agent.move(1),
+ },
+ {
+ id: "agent.cycle.reverse",
+ title: input.language.t("command.agent.cycle.reverse"),
+ description: input.language.t("command.agent.cycle.reverse.description"),
+ category: input.language.t("command.category.agent"),
+ keybind: "shift+mod+.",
+ onSelect: () => input.local.agent.move(-1),
+ },
+ {
+ id: "model.variant.cycle",
+ title: input.language.t("command.model.variant.cycle"),
+ description: input.language.t("command.model.variant.cycle.description"),
+ category: input.language.t("command.category.model"),
+ keybind: "shift+mod+d",
+ onSelect: () => {
+ input.local.model.variant.cycle()
+ },
+ },
+ ])
+
+ const permissionCommands = createMemo(() => [
+ {
+ id: "permissions.autoaccept",
+ title:
+ input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
+ ? input.language.t("command.permissions.autoaccept.disable")
+ : input.language.t("command.permissions.autoaccept.enable"),
+ category: input.language.t("command.category.permissions"),
+ keybind: "mod+shift+a",
+ disabled: !input.params.id || !input.permission.permissionsEnabled(),
+ onSelect: () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
+ showToast({
+ title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.title")
+ : input.language.t("toast.permissions.autoaccept.off.title"),
+ description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.description")
+ : input.language.t("toast.permissions.autoaccept.off.description"),
+ })
+ },
+ },
+ ])
+
+ const sessionActionCommands = createMemo(() => [
+ {
+ id: "session.undo",
+ title: input.language.t("command.session.undo"),
+ description: input.language.t("command.session.undo.description"),
+ category: input.language.t("command.category.session"),
+ slash: "undo",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ if (input.status()?.type !== "idle") {
+ await input.sdk.client.session.abort({ sessionID }).catch(() => {})
+ }
+ const revert = input.info()?.revert?.messageID
+ const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
+ if (!message) return
+ await input.sdk.client.session.revert({ sessionID, messageID: message.id })
+ const parts = input.sync.data.part[message.id]
+ if (parts) {
+ const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
+ input.prompt.set(restored)
+ }
+ const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
+ input.setActiveMessage(priorMessage)
+ },
+ },
+ {
+ id: "session.redo",
+ title: input.language.t("command.session.redo"),
+ description: input.language.t("command.session.redo.description"),
+ category: input.language.t("command.category.session"),
+ slash: "redo",
+ disabled: !input.params.id || !input.info()?.revert?.messageID,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ const revertMessageID = input.info()?.revert?.messageID
+ if (!revertMessageID) return
+ const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
+ if (!nextMessage) {
+ await input.sdk.client.session.unrevert({ sessionID })
+ input.prompt.reset()
+ const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
+ input.setActiveMessage(lastMsg)
+ return
+ }
+ await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+ const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
+ input.setActiveMessage(priorMsg)
+ },
+ },
+ {
+ id: "session.compact",
+ title: input.language.t("command.session.compact"),
+ description: input.language.t("command.session.compact.description"),
+ category: input.language.t("command.category.session"),
+ slash: "compact",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ const model = input.local.model.current()
+ if (!model) {
+ showToast({
+ title: input.language.t("toast.model.none.title"),
+ description: input.language.t("toast.model.none.description"),
+ })
+ return
+ }
+ await input.sdk.client.session.summarize({
+ sessionID,
+ modelID: model.id,
+ providerID: model.provider.id,
+ })
+ },
+ },
+ {
+ id: "session.fork",
+ title: input.language.t("command.session.fork"),
+ description: input.language.t("command.session.fork.description"),
+ category: input.language.t("command.category.session"),
+ slash: "fork",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: () => input.dialog.show(() => <DialogFork />),
+ },
+ ])
+
+ const shareCommands = createMemo(() => {
+ if (input.sync.data.config.share === "disabled") return []
+ return [
+ {
+ id: "session.share",
+ title: input.language.t("command.session.share"),
+ description: input.language.t("command.session.share.description"),
+ category: input.language.t("command.category.session"),
+ slash: "share",
+ disabled: !input.params.id || !!input.info()?.share?.url,
+ onSelect: async () => {
+ if (!input.params.id) return
+ await input.sdk.client.session
+ .share({ sessionID: input.params.id })
+ .then((res) => {
+ navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+ showToast({
+ title: input.language.t("toast.session.share.copyFailed.title"),
+ variant: "error",
+ }),
+ )
+ })
+ .then(() =>
+ showToast({
+ title: input.language.t("toast.session.share.success.title"),
+ description: input.language.t("toast.session.share.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: input.language.t("toast.session.share.failed.title"),
+ description: input.language.t("toast.session.share.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ {
+ id: "session.unshare",
+ title: input.language.t("command.session.unshare"),
+ description: input.language.t("command.session.unshare.description"),
+ category: input.language.t("command.category.session"),
+ slash: "unshare",
+ disabled: !input.params.id || !input.info()?.share?.url,
+ onSelect: async () => {
+ if (!input.params.id) return
+ await input.sdk.client.session
+ .unshare({ sessionID: input.params.id })
+ .then(() =>
+ showToast({
+ title: input.language.t("toast.session.unshare.success.title"),
+ description: input.language.t("toast.session.unshare.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: input.language.t("toast.session.unshare.failed.title"),
+ description: input.language.t("toast.session.unshare.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ ]
+ })
+
+ input.command.register("session", () =>
+ combineCommandSections([
+ sessionCommands(),
+ fileCommands(),
+ contextCommands(),
+ viewCommands(),
+ messageCommands(),
+ agentCommands(),
+ permissionCommands(),
+ sessionActionCommands(),
+ shareCommands(),
+ ]),
+ )
+}
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts
new file mode 100644
index 000000000..844f5451e
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { messageIdFromHash } from "./use-session-hash-scroll"
+
+describe("messageIdFromHash", () => {
+ test("parses hash with leading #", () => {
+ expect(messageIdFromHash("#message-abc123")).toBe("abc123")
+ })
+
+ test("parses raw hash fragment", () => {
+ expect(messageIdFromHash("message-42")).toBe("42")
+ })
+
+ test("ignores non-message anchors", () => {
+ expect(messageIdFromHash("#review-panel")).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
new file mode 100644
index 000000000..8952bbd98
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -0,0 +1,174 @@
+import { createEffect, on, onCleanup } from "solid-js"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+
+export const messageIdFromHash = (hash: string) => {
+ const value = hash.startsWith("#") ? hash.slice(1) : hash
+ const match = value.match(/^message-(.+)$/)
+ if (!match) return
+ return match[1]
+}
+
+export const useSessionHashScroll = (input: {
+ sessionKey: () => string
+ sessionID: () => string | undefined
+ messagesReady: () => boolean
+ visibleUserMessages: () => UserMessage[]
+ turnStart: () => number
+ currentMessageId: () => string | undefined
+ pendingMessage: () => string | undefined
+ setPendingMessage: (value: string | undefined) => void
+ setActiveMessage: (message: UserMessage | undefined) => void
+ setTurnStart: (value: number) => void
+ scheduleTurnBackfill: () => void
+ autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+ scroller: () => HTMLDivElement | undefined
+ anchor: (id: string) => string
+ scheduleScrollState: (el: HTMLDivElement) => void
+ consumePendingMessage: (key: string) => string | undefined
+}) => {
+ const clearMessageHash = () => {
+ if (!window.location.hash) return
+ window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
+ }
+
+ const updateHash = (id: string) => {
+ window.history.replaceState(null, "", `#${input.anchor(id)}`)
+ }
+
+ const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
+ const root = input.scroller()
+ if (!root) return false
+
+ const a = el.getBoundingClientRect()
+ const b = root.getBoundingClientRect()
+ const top = a.top - b.top + root.scrollTop
+ root.scrollTo({ top, behavior })
+ return true
+ }
+
+ const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+ input.setActiveMessage(message)
+
+ const msgs = input.visibleUserMessages()
+ const index = msgs.findIndex((m) => m.id === message.id)
+ if (index !== -1 && index < input.turnStart()) {
+ input.setTurnStart(index)
+ input.scheduleTurnBackfill()
+
+ requestAnimationFrame(() => {
+ const el = document.getElementById(input.anchor(message.id))
+ if (!el) {
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ scrollToElement(next, behavior)
+ })
+ return
+ }
+ scrollToElement(el, behavior)
+ })
+
+ updateHash(message.id)
+ return
+ }
+
+ const el = document.getElementById(input.anchor(message.id))
+ if (!el) {
+ updateHash(message.id)
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
+ return
+ }
+ if (scrollToElement(el, behavior)) {
+ updateHash(message.id)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
+ updateHash(message.id)
+ }
+
+ const applyHash = (behavior: ScrollBehavior) => {
+ const hash = window.location.hash.slice(1)
+ if (!hash) {
+ input.autoScroll.forceScrollToBottom()
+ const el = input.scroller()
+ if (el) input.scheduleScrollState(el)
+ return
+ }
+
+ const messageId = messageIdFromHash(hash)
+ if (messageId) {
+ input.autoScroll.pause()
+ const msg = input.visibleUserMessages().find((m) => m.id === messageId)
+ if (msg) {
+ scrollToMessage(msg, behavior)
+ return
+ }
+ return
+ }
+
+ const target = document.getElementById(hash)
+ if (target) {
+ input.autoScroll.pause()
+ scrollToElement(target, behavior)
+ return
+ }
+
+ input.autoScroll.forceScrollToBottom()
+ const el = input.scroller()
+ if (el) input.scheduleScrollState(el)
+ }
+
+ createEffect(
+ on(input.sessionKey, (key) => {
+ if (!input.sessionID()) return
+ const messageID = input.consumePendingMessage(key)
+ if (!messageID) return
+ input.setPendingMessage(messageID)
+ }),
+ )
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ requestAnimationFrame(() => applyHash("auto"))
+ })
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+
+ input.visibleUserMessages().length
+ input.turnStart()
+
+ const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
+ if (!targetId) return
+ if (input.currentMessageId() === targetId) return
+
+ const msg = input.visibleUserMessages().find((m) => m.id === targetId)
+ if (!msg) return
+
+ if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
+ input.autoScroll.pause()
+ requestAnimationFrame(() => scrollToMessage(msg, "auto"))
+ })
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ const handler = () => requestAnimationFrame(() => applyHash("auto"))
+ window.addEventListener("hashchange", handler)
+ onCleanup(() => window.removeEventListener("hashchange", handler))
+ })
+
+ return {
+ clearMessageHash,
+ scrollToMessage,
+ applyHash,
+ }
+}
diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts
new file mode 100644
index 000000000..9f408b8eb
--- /dev/null
+++ b/packages/app/src/utils/runtime-adapters.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import {
+ disposeIfDisposable,
+ getHoveredLinkText,
+ getSpeechRecognitionCtor,
+ hasSetOption,
+ isDisposable,
+ setOptionIfSupported,
+} from "./runtime-adapters"
+
+describe("runtime adapters", () => {
+ test("detects and disposes disposable values", () => {
+ let count = 0
+ const value = {
+ dispose: () => {
+ count += 1
+ },
+ }
+ expect(isDisposable(value)).toBe(true)
+ disposeIfDisposable(value)
+ expect(count).toBe(1)
+ })
+
+ test("ignores non-disposable values", () => {
+ expect(isDisposable({ dispose: "nope" })).toBe(false)
+ expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow()
+ })
+
+ test("sets options only when setter exists", () => {
+ const calls: Array<[string, unknown]> = []
+ const value = {
+ setOption: (key: string, next: unknown) => {
+ calls.push([key, next])
+ },
+ }
+ expect(hasSetOption(value)).toBe(true)
+ setOptionIfSupported(value, "fontFamily", "Berkeley Mono")
+ expect(calls).toEqual([["fontFamily", "Berkeley Mono"]])
+ expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow()
+ })
+
+ test("reads hovered link text safely", () => {
+ expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com")
+ expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined()
+ expect(getHoveredLinkText(null)).toBeUndefined()
+ })
+
+ test("resolves speech recognition constructor with webkit precedence", () => {
+ class SpeechCtor {}
+ class WebkitCtor {}
+ const ctor = getSpeechRecognitionCtor({
+ SpeechRecognition: SpeechCtor,
+ webkitSpeechRecognition: WebkitCtor,
+ })
+ expect(ctor).toBe(WebkitCtor)
+ })
+
+ test("returns undefined when no valid speech constructor exists", () => {
+ expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined()
+ expect(getSpeechRecognitionCtor(undefined)).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/utils/runtime-adapters.ts b/packages/app/src/utils/runtime-adapters.ts
new file mode 100644
index 000000000..4c74da5dc
--- /dev/null
+++ b/packages/app/src/utils/runtime-adapters.ts
@@ -0,0 +1,39 @@
+type RecordValue = Record<string, unknown>
+
+const isRecord = (value: unknown): value is RecordValue => {
+ return typeof value === "object" && value !== null
+}
+
+export const isDisposable = (value: unknown): value is { dispose: () => void } => {
+ return isRecord(value) && typeof value.dispose === "function"
+}
+
+export const disposeIfDisposable = (value: unknown) => {
+ if (!isDisposable(value)) return
+ value.dispose()
+}
+
+export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => {
+ return isRecord(value) && typeof value.setOption === "function"
+}
+
+export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => {
+ if (!hasSetOption(value)) return
+ value.setOption(key, next)
+}
+
+export const getHoveredLinkText = (value: unknown) => {
+ if (!isRecord(value)) return
+ const link = value.currentHoveredLink
+ if (!isRecord(link)) return
+ if (typeof link.text !== "string") return
+ return link.text
+}
+
+export const getSpeechRecognitionCtor = <T>(value: unknown): (new () => T) | undefined => {
+ if (!isRecord(value)) return
+ const ctor =
+ typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition
+ if (typeof ctor !== "function") return
+ return ctor as new () => T
+}
diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts
new file mode 100644
index 000000000..34c86685a
--- /dev/null
+++ b/packages/app/src/utils/server-health.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test"
+import { checkServerHealth } from "./server-health"
+
+describe("checkServerHealth", () => {
+ test("returns healthy response with version", async () => {
+ const fetch = (async () =>
+ new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })) as unknown as typeof globalThis.fetch
+
+ const result = await checkServerHealth("http://localhost:4096", fetch)
+
+ expect(result).toEqual({ healthy: true, version: "1.2.3" })
+ })
+
+ test("returns unhealthy when request fails", async () => {
+ const fetch = (async () => {
+ throw new Error("network")
+ }) as unknown as typeof globalThis.fetch
+
+ const result = await checkServerHealth("http://localhost:4096", fetch)
+
+ expect(result).toEqual({ healthy: false })
+ })
+
+ test("uses provided abort signal", async () => {
+ let signal: AbortSignal | undefined
+ const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ signal = init?.signal ?? (input instanceof Request ? input.signal : undefined)
+ return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ }) as unknown as typeof globalThis.fetch
+
+ const abort = new AbortController()
+ await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
+
+ expect(signal).toBe(abort.signal)
+ })
+})
diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts
new file mode 100644
index 000000000..ab33460b2
--- /dev/null
+++ b/packages/app/src/utils/server-health.ts
@@ -0,0 +1,29 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+
+export type ServerHealth = { healthy: boolean; version?: string }
+
+interface CheckServerHealthOptions {
+ timeoutMs?: number
+ signal?: AbortSignal
+}
+
+function timeoutSignal(timeoutMs: number) {
+ return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs)
+}
+
+export async function checkServerHealth(
+ url: string,
+ fetch: typeof globalThis.fetch,
+ opts?: CheckServerHealthOptions,
+): Promise<ServerHealth> {
+ const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000)
+ const sdk = createOpencodeClient({
+ baseUrl: url,
+ fetch,
+ signal,
+ })
+ return sdk.global
+ .health()
+ .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
+ .catch(() => ({ healthy: false }))
+}
diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts
index 201c1261b..52fc46b69 100644
--- a/packages/app/src/utils/speech.ts
+++ b/packages/app/src/utils/speech.ts
@@ -1,5 +1,6 @@
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
+import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"
// Minimal types to avoid relying on non-standard DOM typings
type RecognitionResult = {
@@ -56,9 +57,8 @@ export function createSpeechRecognition(opts?: {
onFinal?: (text: string) => void
onInterim?: (text: string) => void
}) {
- const hasSupport =
- typeof window !== "undefined" &&
- Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
+ const ctor = getSpeechRecognitionCtor<Recognition>(typeof window === "undefined" ? undefined : window)
+ const hasSupport = Boolean(ctor)
const [store, setStore] = createStore({
isRecording: false,
@@ -155,10 +155,8 @@ export function createSpeechRecognition(opts?: {
}, COMMIT_DELAY)
}
- if (hasSupport) {
- const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
-
- recognition = new Ctor()
+ if (ctor) {
+ recognition = new ctor()
recognition.continuous = false
recognition.interimResults = true
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts
new file mode 100644
index 000000000..8161e7ad8
--- /dev/null
+++ b/packages/app/src/utils/worktree.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "bun:test"
+import { Worktree } from "./worktree"
+
+const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}`
+
+describe("Worktree", () => {
+ test("normalizes trailing slashes", () => {
+ const key = dir("normalize")
+ Worktree.ready(`${key}/`)
+
+ expect(Worktree.get(key)).toEqual({ status: "ready" })
+ })
+
+ test("pending does not overwrite a terminal state", () => {
+ const key = dir("pending")
+ Worktree.failed(key, "boom")
+ Worktree.pending(key)
+
+ expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" })
+ })
+
+ test("wait resolves shared pending waiter when ready", async () => {
+ const key = dir("wait-ready")
+ Worktree.pending(key)
+
+ const a = Worktree.wait(key)
+ const b = Worktree.wait(`${key}/`)
+
+ expect(a).toBe(b)
+
+ Worktree.ready(key)
+
+ expect(await a).toEqual({ status: "ready" })
+ expect(await b).toEqual({ status: "ready" })
+ })
+
+ test("wait resolves with failure message", async () => {
+ const key = dir("wait-failed")
+ const waiting = Worktree.wait(key)
+
+ Worktree.failed(key, "permission denied")
+
+ expect(await waiting).toEqual({ status: "failed", message: "permission denied" })
+ expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" })
+ })
+})