diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0dbd04f8215..f21513c86a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,6 +115,8 @@ jobs: target: x86_64-apple-darwin - host: macos-latest target: aarch64-apple-darwin + - host: blacksmith-4vcpu-windows-2025 + target: aarch64-pc-windows-msvc - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 @@ -212,6 +214,27 @@ jobs: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Setup Windows ARM64 clang + if: runner.os == 'Windows' && matrix.settings.target == 'aarch64-pc-windows-msvc' + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (!(Test-Path $vswhere)) { throw "vswhere.exe not found at $vswhere" } + $root = & $vswhere -latest -products * -property installationPath + if (!$root) { throw "Visual Studio installation not found" } + $llvm = Join-Path $root "VC\Tools\Llvm" + $bin = @( + (Join-Path $llvm "x64\bin"), + (Join-Path $llvm "bin") + ) | Where-Object { Test-Path (Join-Path $_ "clang.exe") } | Select-Object -First 1 + if (!$bin -and (Test-Path $llvm)) { + $bin = Get-ChildItem -Path $llvm -Filter clang.exe -Recurse -File | Select-Object -First 1 | ForEach-Object { $_.DirectoryName } + } + if (!$bin) { throw "clang.exe not found under $llvm" } + $env:PATH = "$bin;$env:PATH" + Add-Content -Path $env:GITHUB_PATH -Value $bin + clang --version + - name: Build and upload artifacts uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a timeout-minutes: 60 @@ -254,6 +277,9 @@ jobs: - host: macos-latest target: aarch64-apple-darwin platform_flag: --mac --arm64 + - host: "blacksmith-4vcpu-windows-2025" + target: aarch64-pc-windows-msvc + platform_flag: --win --arm64 - host: "blacksmith-4vcpu-windows-2025" target: x86_64-pc-windows-msvc platform_flag: --win diff --git a/AGENTS.md b/AGENTS.md index 2158d73af1b..0b080ac4e26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,3 +122,7 @@ const table = sqliteTable("session", { - Avoid mocks as much as possible - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. + +## Type Checking + +- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. diff --git a/bun.lock b/bun.lock index 97292974d24..65664f1cb28 100644 --- a/bun.lock +++ b/bun.lock @@ -369,6 +369,7 @@ "solid-js": "catalog:", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", "vscode-jsonrpc": "8.2.1", @@ -565,8 +566,9 @@ }, }, "trustedDependencies": [ - "electron", "esbuild", + "tree-sitter-powershell", + "electron", "web-tree-sitter", "tree-sitter-bash", ], @@ -4368,6 +4370,8 @@ "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], + "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], diff --git a/nix/hashes.json b/nix/hashes.json index 326cc98a667..cc5efd32f02 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=", - "aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=", - "aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=", - "x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE=" + "x86_64-linux": "sha256-CdgMDfqrB9R/mSzmpEFUIN6ZC4ePvwHtt+2gcblQ4PA=", + "aarch64-linux": "sha256-gy/nTLhvo15U+7xLtanj43FE6eDh9gMpBhaiqvroiwY=", + "aarch64-darwin": "sha256-DxXzKnYFVR4kYXwNKVsLztC0F/0lEhoCK7WQ55WTsZ0=", + "x86_64-darwin": "sha256-/b3O2eSjpbpq5HZRuYZveuJHbklf1tUtSecc+afWc6Y=" } } diff --git a/package.json b/package.json index 530ab937c22..ec8c3488f3f 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "protobufjs", "tree-sitter", "tree-sitter-bash", + "tree-sitter-powershell", "web-tree-sitter", "electron" ], diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 919a1add815..51d9e08500a 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -8,6 +8,7 @@ import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + sessionHeaderSelector, projectMenuTriggerSelector, projectWorkspacesToggleSelector, titlebarRightSelector, @@ -229,9 +230,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) { export async function openSessionMoreMenu(page: Page, sessionID: string) { await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) - const scroller = page.locator(".scroll-view__viewport").first() - await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + const header = page.locator(sessionHeaderSelector).first() + await expect(header).toBeVisible() + await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) @@ -247,7 +248,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { if (opened) return menu - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + const menuTrigger = header.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b52..d546cc668ef 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte export const inlineInputSelector = '[data-component="inline-input"]' +export const sessionHeaderSelector = "[data-session-title]" + export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const workspaceItemSelector = (slug: string) => diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 68d99294996..afbea91ca68 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -7,7 +7,7 @@ import { openSharePopover, withSession, } from "../actions" -import { sessionItemSelector, inlineInputSelector } from "../selectors" +import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" @@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first() await expect(input).toBeVisible() await expect(input).toBeFocused() await input.fill(renamedTitle) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 532edd3bcdc..ccae966289f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -254,7 +254,17 @@ export const PromptInput: Component = (props) => { applyingHistory: false, }) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const buttonsSpring = useSpring( + () => (store.mode === "normal" ? 1 : 0), + { visualDuration: 0.2, bounce: 0 }, + ) + + const springFade = (t: number): Record => ({ + opacity: `${t}`, + transform: `scale(${0.95 + t * 0.05})`, + filter: `blur(${(1 - t) * 2}px)`, + "pointer-events": t > 0.5 ? "auto" : "none", + }) const commentCount = createMemo(() => { if (store.mode === "shell") return 0 @@ -1246,9 +1256,7 @@ export const PromptInput: Component = (props) => {
0.5 ? "auto" : "none", - }} + style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }} > = (props) => { type="button" variant="ghost" class="size-8 p-0" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} onClick={pick} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} @@ -1302,11 +1306,7 @@ export const PromptInput: Component = (props) => { icon={working() ? "stop" : "arrow-up"} variant="primary" class="size-8" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -1362,13 +1362,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.mode.shell")}
@@ -1387,13 +1381,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> @@ -1411,13 +1399,7 @@ export const PromptInput: Component = (props) => { variant="ghost" size="normal" class="min-w-0 max-w-[320px] text-13-regular group" - style={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + style={{ height: "28px", ...springFade(buttonsSpring()) }} onClick={() => dialog.show(() => )} > @@ -1446,13 +1428,7 @@ export const PromptInput: Component = (props) => { triggerProps={{ variant: "ghost", size: "normal", - style: { - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }, + style: { height: "28px", ...springFade(buttonsSpring()) }, class: "min-w-0 max-w-[320px] text-13-regular group", }} > @@ -1484,13 +1460,7 @@ export const PromptInput: Component = (props) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index db1b5a5ca17..5a8abc02014 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,8 +1,9 @@ import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" +import { errorMessage } from "@/pages/layout/helpers" import { useNavigate, useParams } from "@solidjs/router" -import type { Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const language = useLanguage() const params = useParams() - 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 toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) const abort = async () => { const sessionID = params.id @@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(err), + description: toastError(err), }) return undefined }) @@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), + description: toastError(err), }) return undefined }) @@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), + description: toastError(err), }) restoreInput() }) @@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { messageID, }) - removeCommentItems(commentItems) - clearInput() - addOptimisticMessage() + batch(() => { + removeCommentItems(commentItems) + clearInput() + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + addOptimisticMessage() + }) const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) @@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), + description: toastError(err), }) removeOptimisticMessage() restoreCommentItems(commentItems) diff --git a/packages/app/src/components/session/find-assistant-messages.test.ts b/packages/app/src/components/session/find-assistant-messages.test.ts new file mode 100644 index 00000000000..ab944e2cc9f --- /dev/null +++ b/packages/app/src/components/session/find-assistant-messages.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages" + +function user(id: string): Message { + return { + id, + role: "user", + sessionID: "session-1", + time: { created: 1 }, + } as unknown as Message +} + +function assistant(id: string, parentID: string): Message { + return { + id, + role: "assistant", + sessionID: "session-1", + parentID, + time: { created: 1 }, + } as unknown as Message +} + +describe("findAssistantMessages", () => { + test("normal ordering: assistant after user in array → found via forward scan", () => { + const messages = [user("u1"), assistant("a1", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("clock skew: assistant before user in array → found via backward scan", () => { + // When client clock is ahead, user ID sorts after assistant ID, + // so assistant appears earlier in the ID-sorted message array + const messages = [assistant("a1", "u1"), user("u1")] + const result = findAssistantMessages(messages, 1, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("no assistant messages → returns empty array", () => { + const messages = [user("u1"), user("u2")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(0) + }) + + test("multiple assistant messages with matching parentID → all found", () => { + const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(2) + expect(result[0].id).toBe("a1") + expect(result[1].id).toBe("a2") + }) + + test("does not return assistant messages with different parentID", () => { + const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("stops forward scan at next user message", () => { + const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("stops backward scan at previous user message", () => { + const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")] + const result = findAssistantMessages(messages, 3, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("invalid index returns empty array", () => { + const messages = [user("u1")] + expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0) + expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0) + }) +}) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 39eb4b4c0eb..0a13e46f695 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" -import { same } from "@/utils/same" +import { findLast, same } from "@opencode-ai/util/array" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index c2b5a1ef449..0bcf87dde66 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -266,6 +266,9 @@ export function Titlebar() {
+
+ BETA +
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318dab..00d0511a22d 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { isEditableTarget } from "@/utils/dom" import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -177,14 +178,6 @@ export function formatKeybind(config: string): string { return IS_MAC ? parts.join("") : parts.join("+") } -function isEditableTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false - if (target.isContentEditable) return true - if (target.closest("[contenteditable='true']")) return true - if (target.closest("input, textarea, select")) return true - return false -} - export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b3a351382f3..85945a80b39 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -353,7 +353,6 @@ function createGlobalSync() { .update({ config }) .then(bootstrap) .then(() => { - queue.refresh() setGlobalStore("reload", undefined) queue.refresh() }) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5199e5a26be..130cc3a5cee 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,7 +8,7 @@ import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { decode64 } from "@/utils/base64" -import { same } from "@/utils/same" +import { same } from "@opencode-ai/util/array" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cf2c3b6c433..8772934883e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1934,33 +1934,34 @@ export default function Layout(props: ParentProps) { when={workspacesEnabled()} fallback={ <> -
- -
+ + + } />
} > <> -
- -
{ if (!panelProps.mobile) scrollContainerRef = el }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]" > - - - {(directory) => ( - - )} - - +
+
+ + + +
+
+
+ + + {(directory) => ( + + )} + + +
{ return (
mobile?: boolean + header?: JSX.Element }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() @@ -488,9 +489,14 @@ export const LocalWorkspace = (props: { return (
props.ctx.setScrollContainerRef(el, props.mobile)} - class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]" > -
} + > - - - {language.t("session.review.loadingChanges")}
} - > - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - - - -
-
Create a Git repository
-
- Track, review, and undo changes in this project -
+ + + + +
+
Create a Git repository
+
+ Track, review, and undo changes in this project
- -
- ) : ( -
-
{language.t(reviewEmptyKey())}
- ) - } - diffs={reviewDiffs} - view={view} - diffStyle={input.diffStyle} - onDiffStyleChange={input.onDiffStyleChange} - onScrollRef={(el) => setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> -
- - + +
+ ) : ( +
+
{language.t(reviewEmptyKey())}
+
+ ) + } + diffs={reviewDiffs} + view={view} + diffStyle={input.diffStyle} + onDiffStyleChange={input.onDiffStyleChange} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> +
+ ) const reviewPanel = () => ( @@ -1064,7 +1041,10 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 + // If auto-scroll is tracking the bottom, always report bottom: true + // to prevent the scroll-down arrow from flashing during height animations + // With column-reverse, scrollTop=0 is at the bottom + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1087,7 +1067,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1155,9 +1135,8 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false + // With column-reverse, near bottom = scrollTop near 0 + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next @@ -1223,50 +1202,49 @@ export default function Page() {
- - { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} - anchor={anchor} - onRegisterMessage={scrollSpy.register} - onUnregisterMessage={scrollSpy.unregister} - /> - + { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={historyWindow.turnStart()} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + void historyWindow.loadAndReveal() + }} + renderedUserMessages={historyWindow.renderedUserMessages()} + anchor={anchor} + onRegisterMessage={scrollSpy.register} + onUnregisterMessage={scrollSpy.unregister} + /> { inputRef = el diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 93ea3d465c5..6f9b16f336e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,7 +1,7 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { Show, createMemo, createSignal, createEffect } from "solid-js" import { useParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useElementHeight } from "@opencode-ai/ui/hooks" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" -import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock" + +const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 } export function SessionComposerRegion(props: { state: SessionComposerState - ready: boolean centered: boolean inputRef: (el: HTMLDivElement) => void newSessionWorktree: string @@ -21,23 +22,6 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void - visualDuration?: number - bounce?: number - dockOpenVisualDuration?: number - dockOpenBounce?: number - dockCloseVisualDuration?: number - dockCloseBounce?: number - drawerExpandVisualDuration?: number - drawerExpandBounce?: number - drawerCollapseVisualDuration?: number - drawerCollapseBounce?: number - subtitleDuration?: number - subtitleTravel?: number - subtitleEdge?: number - countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -63,73 +47,15 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [gate, setGate] = createStore({ - ready: false, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) { - window.clearTimeout(timer) - timer = undefined - } - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } - } - - createEffect(() => { - sessionKey() - const ready = props.ready - const delay = 140 - - clear() - setGate("ready", false) - if (!ready) return - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setGate("ready", true) - timer = undefined - }, delay) - }) - }) - - onCleanup(clear) - - const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing()) - const config = createMemo(() => - open() - ? { - visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockOpenBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockCloseBounce ?? props.bounce ?? 0, - }, + const open = createMemo(() => props.state.dock() && !props.state.closing()) + const progress = useSpring( + () => (open() ? 1 : 0), + DOCK_SPRING, ) - const progress = useSpring(() => (open() ? 1 : 0), config) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) - const full = createMemo(() => Math.max(78, height())) + const dock = createMemo(() => props.state.dock() || progress() > 0.001) const [contentRef, setContentRef] = createSignal() - - createEffect(() => { - const el = contentRef() - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: { title={language.t("session.todo.title")} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} - dockProgress={value()} - visualDuration={props.visualDuration} - bounce={props.bounce} - expandVisualDuration={props.drawerExpandVisualDuration} - expandBounce={props.drawerExpandBounce} - collapseVisualDuration={props.drawerCollapseVisualDuration} - collapseBounce={props.drawerCollapseBounce} - subtitleDuration={props.subtitleDuration} - subtitleTravel={props.subtitleTravel} - subtitleEdge={props.subtitleEdge} - countDuration={props.countDuration} - countMask={props.countMask} - countMaskHeight={props.countMaskHeight} - countWidthDuration={props.countWidthDuration} + dockProgress={progress()} />
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: { "relative z-10": true, }} style={{ - "margin-top": `${-36 * value()}px`, + "margin-top": `${-36 * progress()}px`, }} > number) }) { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b22a92eb0af..70b12474391 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" import { showToast } from "@opencode-ai/ui/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" @@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit customOn: cached?.customOn ?? ([] as boolean[]), editing: false, sending: false, + collapsed: false, }) let root: HTMLDivElement | undefined @@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) + const picked = createMemo(() => store.answers[store.tab]?.length ?? 0) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const last = createMemo(() => store.tab >= total() - 1) + const fold = () => setStore("collapsed", (value) => !value) + const customUpdate = (value: string, selected: boolean = on()) => { const prev = input().trim() const next = value.trim() @@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit kind="question" ref={(el) => (root = el)} header={ - <> +
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + fold() + }} + >
{summary()}
-
+
{(_, i) => (
- +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + fold() + }} + aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")} + /> +
+
} footer={ <> @@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } > -
{question()?.question}
- {language.t("ui.question.singleHint")}
}> -
{language.t("ui.question.multiHint")}
+
{ + if (!store.collapsed) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + fold() + }} + > + {question()?.question} +
+ 0}> +
+ {picked()} answer{picked() === 1 ? "" : "s"} selected +
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( + }> +
{language.t("ui.question.multiHint")}
+ +
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + selectOption(i())} + onClick={customOpen} > -