diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages')
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" }) + }) +}) |
