diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip | |
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <[email protected]>
93 files changed, 5326 insertions, 4386 deletions
diff --git a/packages/app/e2e/files/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts index 3c636d748..abb28242d 100644 --- a/packages/app/e2e/files/file-open.spec.ts +++ b/packages/app/e2e/files/file-open.spec.ts @@ -1,15 +1,28 @@ import { test, expect } from "../fixtures" -import { openPalette, clickListItem } from "../actions" +import { promptSelector } from "../selectors" test("can open a file tab from the search palette", async ({ page, gotoSession }) => { await gotoSession() - const dialog = await openPalette(page) + await page.locator(promptSelector).click() + await page.keyboard.type("/open") + + const command = page.locator('[data-slash-id="file.open"]').first() + await expect(command).toBeVisible() + await page.keyboard.press("Enter") + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() const input = dialog.getByRole("textbox").first() await input.fill("package.json") - await clickListItem(dialog, { keyStartsWith: "file:" }) + const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(item).toBeVisible({ timeout: 30_000 }) + await item.click() await expect(dialog).toHaveCount(0) diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts index 528384497..b968acc13 100644 --- a/packages/app/e2e/files/file-viewer.spec.ts +++ b/packages/app/e2e/files/file-viewer.spec.ts @@ -1,18 +1,41 @@ import { test, expect } from "../fixtures" -import { openPalette, clickListItem } from "../actions" +import { promptSelector } from "../selectors" test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { await gotoSession() - const sep = process.platform === "win32" ? "\\" : "/" - const file = ["packages", "app", "package.json"].join(sep) + await page.locator(promptSelector).click() + await page.keyboard.type("/open") - const dialog = await openPalette(page) + const command = page.locator('[data-slash-id="file.open"]').first() + await expect(command).toBeVisible() + await page.keyboard.press("Enter") - const input = dialog.getByRole("textbox").first() - await input.fill(file) + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() - await clickListItem(dialog, { text: /packages.*app.*package.json/ }) + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') + let index = -1 + await expect + .poll( + async () => { + const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) + index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) + return index >= 0 + }, + { timeout: 30_000 }, + ) + .toBe(true) + + const item = items.nth(index) + await expect(item).toBeVisible() + await item.click() await expect(dialog).toHaveCount(0) @@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession } const code = page.locator('[data-component="code"]').first() await expect(code).toBeVisible() - await expect(code.getByText("@opencode-ai/app")).toBeVisible() + await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() }) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 5af314caf..f33972cc3 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() + await expect(prompt).toBeEditable() await prompt.click() - await page.keyboard.type(text) - await page.keyboard.press("Enter") + await expect(prompt).toBeFocused() + await prompt.fill(text) + await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) + await prompt.press("Enter") await expect.poll(() => slugFromUrl(page.url())).toBe(slug) - await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 }) + await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) return sessionID } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 071c398b2..386739526 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -11,18 +11,12 @@ import { cleanupTestProject, clickMenuItem, confirmDialog, - openProjectMenu, openSidebar, openWorkspaceMenu, setWorkspacesEnabled, } from "../actions" -import { - inlineInputSelector, - projectSwitchSelector, - projectWorkspacesToggleSelector, - workspaceItemSelector, -} from "../selectors" -import { dirSlug } from "../utils" +import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { createSdk, dirSlug } from "../utils" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" @@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") try { - await withProject( - async () => { - await openSidebar(page) + await withProject(async () => { + await page.goto(`/${nonGitSlug}/session`) + + await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first() - await expect(nonGitButton).toBeVisible() - await nonGitButton.click() - await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`)) + const activeDir = base64Decode(slugFromUrl(page.url())) + expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") - const menu = await openProjectMenu(page, nonGitSlug) - const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first() + await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await expect(toggle).toBeVisible() - await expect(toggle).toBeDisabled() + const trigger = page.locator('[data-action="project-menu"]').first() + const hasMenu = await trigger + .isVisible() + .then((x) => x) + .catch(() => false) + if (!hasMenu) return - await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - }, - { extra: [nonGit] }, - ) + await trigger.click({ force: true }) + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + + const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() + + await expect(toggle).toBeVisible() + await expect(toggle).toBeDisabled() + await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) + }) } finally { await cleanupTestProject(nonGit) } @@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async (project) => { - const { rootSlug, slug } = await setupWorkspaceTest(page, project) + const sdk = createSdk(project.directory) + const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) + + await expect + .poll( + async () => { + const worktrees = await sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 30_000 }, + ) + .toBe(true) const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + + await expect + .poll( + async () => { + const worktrees = await sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 60_000 }, + ) + .toBe(false) + + await project.gotoSession() + + await openSidebar(page) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() }) }) diff --git a/packages/app/e2e/prompt/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts index 80aa9ea33..366191fd7 100644 --- a/packages/app/e2e/prompt/context.spec.ts +++ b/packages/app/e2e/prompt/context.spec.ts @@ -1,40 +1,95 @@ import { test, expect } from "../fixtures" +import type { Page } from "@playwright/test" import { promptSelector } from "../selectors" import { withSession } from "../actions" +function contextButton(page: Page) { + return page + .locator('[data-component="button"]') + .filter({ has: page.locator('[data-component="progress-circle"]').first() }) + .first() +} + +async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) { + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [ + { + type: "text", + text: "seed context", + }, + ], + }) + + await expect + .poll(async () => { + const messages = await input.sdk.session + .messages({ sessionID: input.sessionID, limit: 1 }) + .then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) +} + test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` await withSession(sdk, title, async (session) => { - await sdk.session.promptAsync({ - sessionID: session.id, - noReply: true, - parts: [ - { - type: "text", - text: "seed context", - }, - ], - }) + await seedContextSession({ sessionID: session.id, sdk }) - await expect - .poll(async () => { - const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? []) - return messages.length - }) - .toBeGreaterThan(0) + await gotoSession(session.id) + + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + }) +}) +test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => { + await seedContextSession({ sessionID: session.id, sdk }) await gotoSession(session.id) - const contextButton = page - .locator('[data-component="button"]') - .filter({ has: page.locator('[data-component="progress-circle"]').first() }) - .first() + await page.locator(promptSelector).click() - await expect(contextButton).toBeVisible() - await contextButton.click() + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + const context = tabs.getByRole("tab", { name: "Context" }) + await expect(context).toBeVisible() + + await page.getByRole("button", { name: "Close tab" }).first().click() + await expect(context).toHaveCount(0) + }) +}) + +test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => { + await seedContextSession({ sessionID: session.id, sdk }) + await gotoSession(session.id) + + await page.locator(promptSelector).click() + + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() + + await expect(page.getByRole("tab", { name: "Context" })).toBeVisible() + await page.getByRole("button", { name: "Open file" }).first().click() + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) }) }) diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts index 07d242c63..ff9f5daf0 100644 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) ) .toContain(token) - - const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first() - await expect(reply).toBeVisible({ timeout: 90_000 }) } finally { page.off("pageerror", onPageError) await sdk.session.delete({ sessionID }).catch(() => undefined) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index 2a250dd86..c6ea2aea0 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -10,21 +10,26 @@ async function seedConversation(input: { sessionID: string token: string }) { + const messages = async () => + await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? []) + const seeded = await messages() + const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id)) + const prompt = input.page.locator(promptSelector) await expect(prompt).toBeVisible() - await prompt.click() - await input.page.keyboard.type(`Reply with exactly: ${input.token}`) - await input.page.keyboard.press("Enter") + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [{ type: "text", text: input.token }], + }) let userMessageID: string | undefined await expect .poll( async () => { - const messages = await input.sdk.session - .messages({ sessionID: input.sessionID, limit: 50 }) - .then((r) => r.data ?? []) - const users = messages.filter( + const users = (await messages()).filter( (m) => + !userIDs.has(m.info.id) && m.info.role === "user" && m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)), ) @@ -33,21 +38,14 @@ async function seedConversation(input: { const user = users[users.length - 1] if (!user) return false userMessageID = user.info.id - - const assistantText = messages - .filter((m) => m.info.role === "assistant") - .flatMap((m) => m.parts) - .filter((p) => p.type === "text") - .map((p) => p.text) - .join("\n") - - return assistantText.includes(input.token) + return true }, - { timeout: 90_000 }, + { timeout: 90_000, intervals: [250, 500, 1_000] }, ) .toBe(true) if (!userMessageID) throw new Error("Expected a user message id") + await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 }) return { prompt, userMessageID } } diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 4610fb331..93eaee5cb 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) { test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() const originalTitle = `e2e rename test ${stamp}` - const newTitle = `e2e renamed ${stamp}` + const renamedTitle = `e2e renamed ${stamp}` await withSession(sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id) await gotoSession(session.id) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) const input = page.locator(".session-scroller").locator(inlineInputSelector).first() await expect(input).toBeVisible() - await input.fill(newTitle) + await expect(input).toBeFocused() + await input.fill(renamedTitle) + await expect(input).toHaveValue(renamedTitle) await input.press("Enter") - await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle) + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.title + }, + { timeout: 30_000 }, + ) + .toBe(renamedTitle) + + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) }) }) @@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk, await seedMessage(sdk, session.id) await gotoSession(session.id) - const { rightSection, popoverBody } = await openSharePopover(page) - await popoverBody.getByRole("button", { name: "Publish" }).first().click() + const shared = await openSharePopover(page) + const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() + await expect(publish).toBeVisible({ timeout: 30_000 }) + await publish.click() + + await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ + timeout: 30_000, + }) await expect .poll( @@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk, ) .not.toBeUndefined() - const copyButton = rightSection.locator('button[aria-label="Copy link"]').first() - await expect(copyButton).toBeVisible({ timeout: 30_000 }) - - const sharedPopover = await openSharePopover(page) - const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first() + const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() await expect(unpublish).toBeVisible({ timeout: 30_000 }) await unpublish.click() + await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) + await expect .poll( async () => { @@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk, ) .toBeUndefined() - await expect(copyButton).not.toBeVisible({ timeout: 30_000 }) - - const unsharedPopover = await openSharePopover(page) - await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + const unshared = await openSharePopover(page) + await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ timeout: 30_000, }) }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e49b725a1..3032a795f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js" +import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense, JSX } from "solid-js" - const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () => <div class="size-full" /> +const HomeRoute = () => ( + <Suspense fallback={<Loading />}> + <Home /> + </Suspense> +) + +const SessionRoute = () => ( + <SessionProviders> + <Suspense fallback={<Loading />}> + <Session /> + </Suspense> + </SessionProviders> +) + +const SessionIndexRoute = () => <Navigate href="session" /> + function UiI18nBridge(props: ParentProps) { const language = useLanguage() return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> @@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) { return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> } +function AppShellProviders(props: ParentProps) { + return ( + <SettingsProvider> + <PermissionProvider> + <LayoutProvider> + <NotificationProvider> + <ModelsProvider> + <CommandProvider> + <HighlightsProvider> + <Layout>{props.children}</Layout> + </HighlightsProvider> + </CommandProvider> + </ModelsProvider> + </NotificationProvider> + </LayoutProvider> + </PermissionProvider> + </SettingsProvider> + ) +} + +function SessionProviders(props: ParentProps) { + return ( + <TerminalProvider> + <FileProvider> + <PromptProvider> + <CommentsProvider>{props.children}</CommentsProvider> + </PromptProvider> + </FileProvider> + </TerminalProvider> + ) +} + +function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + return ( + <AppShellProviders> + {props.appChildren} + {props.children} + </AppShellProviders> + ) +} + +const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => { + if (platform.platform !== "web") return + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) return + if (!result) return + return normalizeServerUrl(result) +} + +const resolveDefaultServerUrl = (props: { + defaultUrl?: string + storedDefaultServerUrl?: string + hostname: string + origin: string + isDev: boolean + devHost?: string + devPort?: string +}) => { + if (props.defaultUrl) return props.defaultUrl + if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl + if (props.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}` + return props.origin +} + export function AppBaseProviders(props: ParentProps) { return ( <MetaProvider> @@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) { function ServerKey(props: ParentProps) { const server = useServer() - return ( - <Show when={server.url} keyed> - {props.children} - </Show> - ) + if (!server.url) return null + return props.children } export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { const platform = usePlatform() - - const stored = (() => { - if (platform.platform !== "web") return - const result = platform.getDefaultServerUrl?.() - if (result instanceof Promise) return - if (!result) return - return normalizeServerUrl(result) - })() - - const defaultServerUrl = () => { - if (props.defaultUrl) return props.defaultUrl - if (stored) return stored - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - - return window.location.origin - } + const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) + const defaultServerUrl = resolveDefaultServerUrl({ + defaultUrl: props.defaultUrl, + storedDefaultServerUrl, + hostname: location.hostname, + origin: window.location.origin, + isDev: import.meta.env.DEV, + devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST, + devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT, + }) return ( - <ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}> + <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}> <ServerKey> <GlobalSDKProvider> <GlobalSyncProvider> <Router - root={(routerProps) => ( - <SettingsProvider> - <PermissionProvider> - <LayoutProvider> - <NotificationProvider> - <ModelsProvider> - <CommandProvider> - <HighlightsProvider> - <Layout> - {props.children} - {routerProps.children} - </Layout> - </HighlightsProvider> - </CommandProvider> - </ModelsProvider> - </NotificationProvider> - </LayoutProvider> - </PermissionProvider> - </SettingsProvider> - )} + root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} > - <Route - path="/" - component={() => ( - <Suspense fallback={<Loading />}> - <Home /> - </Suspense> - )} - /> + <Route path="/" component={HomeRoute} /> <Route path="/:dir" component={DirectoryLayout}> - <Route path="/" component={() => <Navigate href="session" />} /> - <Route - path="/session/:id?" - component={(p) => ( - <Show when={p.params.id ?? "new"}> - <TerminalProvider> - <FileProvider> - <PromptProvider> - <CommentsProvider> - <Suspense fallback={<Loading />}> - <Session /> - </Suspense> - </CommentsProvider> - </PromptProvider> - </FileProvider> - </TerminalProvider> - </Show> - )} - /> + <Route path="/" component={SessionIndexRoute} /> + <Route path="/session/:id?" component={SessionRoute} /> </Route> </Router> </GlobalSyncProvider> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 65e322b43..4d24b2315 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { iife } from "@opencode-ai/util/iife" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" @@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) { error: undefined as string | undefined, }) + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.error"; error: string } + + function dispatch(action: Action) { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const methodLabel = (value?: { type?: string; label?: string }) => { @@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) { } const method = methods()[index] - setStore( - produce((draft) => { - draft.methodIndex = index - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) + dispatch({ type: "method.select", index }) if (method.type === "oauth") { - setStore("state", "pending") + dispatch({ type: "auth.pending" }) const start = Date.now() await globalSDK.client.provider.oauth .authorize( @@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) { timer.current = setTimeout(() => { timer.current = undefined if (!alive.value) return - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }, delay) return } - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }) .catch((e) => { if (!alive.value) return - setStore("state", "error") - setStore("error", String(e)) + dispatch({ type: "auth.error", error: String(e) }) }) } } @@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) }) async function complete() { @@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) { return } if (store.authorization) { - setStore("authorization", undefined) - setStore("methodIndex", undefined) + dispatch({ type: "method.reset" }) return } - if (store.methodIndex) { - setStore("methodIndex", undefined) + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) return } dialog.show(() => <DialogSelectProvider />) } + function MethodSelection() { + return ( + <> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.selectMethod", { provider: provider().name })} + </div> + <div> + <List + ref={(ref) => { + listRef = ref + }} + items={methods} + key={(m) => m?.label} + onSelect={async (selected, index) => { + if (!selected) return + selectMethod(index) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2"> + <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> + <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> + </div> + <span>{methodLabel(i)}</span> + </div> + )} + </List> + </div> + </> + ) + } + + function ApiAuthView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( + <div class="flex flex-col gap-6"> + <Switch> + <Match when={provider().id === "opencode"}> + <div class="flex flex-col gap-4"> + <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div> + <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.opencodeZen.visit.prefix")} + <Link href="https://opencode.ai/zen" tabIndex={-1}> + {language.t("provider.connect.opencodeZen.visit.link")} + </Link> + {language.t("provider.connect.opencodeZen.visit.suffix")} + </div> + </div> + </Match> + <Match when={true}> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.apiKey.description", { provider: provider().name })} + </div> + </Match> + </Switch> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={language.t("provider.connect.apiKey.label", { provider: provider().name })} + placeholder={language.t("provider.connect.apiKey.placeholder")} + name="apiKey" + value={formStore.value} + onChange={(v) => setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + {language.t("common.submit")} + </Button> + </form> + </div> + ) + } + + function OAuthCodeView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await complete() + return + } + const message = result.error instanceof Error ? result.error.message : String(result.error) + setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) + } + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.oauth.code.visit.prefix")} + <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link> + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} + </div> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} + placeholder={language.t("provider.connect.oauth.code.placeholder")} + name="code" + value={formStore.value} + onChange={(v) => setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + {language.t("common.submit")} + </Button> + </form> + </div> + ) + } + + function OAuthAutoView() { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions.split(":")[1]?.trim() + } + return instructions + }) + + onMount(() => { + void (async () => { + if (store.authorization?.url) { + platform.openLink(store.authorization.url) + } + + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + + if (!alive.value) return + + if (!result.ok) { + const message = result.error instanceof Error ? result.error.message : String(result.error) + dispatch({ type: "auth.error", error: message }) + return + } + + await complete() + })() + }) + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + {language.t("provider.connect.oauth.auto.visit.prefix")} + <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link> + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} + </div> + <TextField + label={language.t("provider.connect.oauth.auto.confirmationCode")} + class="font-mono" + value={code()} + readOnly + copyable + /> + <div class="text-14-regular text-text-base flex items-center gap-4"> + <Spinner /> + <span>{language.t("provider.connect.status.waiting")}</span> + </div> + </div> + ) + } + return ( <Dialog title={ @@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) { </div> </div> <div class="px-2.5 pb-10 flex flex-col gap-6"> - <Switch> - <Match when={store.methodIndex === undefined}> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.selectMethod", { provider: provider().name })} - </div> - <div class=""> - <List - ref={(ref) => { - listRef = ref - }} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2"> - <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> - <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> - </div> - <span>{methodLabel(i)}</span> - </div> - )} - </List> - </div> - </Match> - <Match when={store.state === "pending"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-2"> - <Spinner /> - <span>{language.t("provider.connect.status.inProgress")}</span> - </div> - </div> - </Match> - <Match when={store.state === "error"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-2"> - <Icon name="circle-ban-sign" class="text-icon-critical-base" /> - <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> + <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> + <Switch> + <Match when={store.methodIndex === undefined}> + <MethodSelection /> + </Match> + <Match when={store.state === "pending"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-2"> + <Spinner /> + <span>{language.t("provider.connect.status.inProgress")}</span> + </div> </div> - </div> - </Match> - <Match when={method()?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", language.t("provider.connect.apiKey.required")) - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( - <div class="flex flex-col gap-6"> - <Switch> - <Match when={provider().id === "opencode"}> - <div class="flex flex-col gap-4"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.line1")} - </div> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.line2")} - </div> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.opencodeZen.visit.prefix")} - <Link href="https://opencode.ai/zen" tabIndex={-1}> - {language.t("provider.connect.opencodeZen.visit.link")} - </Link> - {language.t("provider.connect.opencodeZen.visit.suffix")} - </div> - </div> - </Match> - <Match when={true}> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.apiKey.description", { provider: provider().name })} - </div> - </Match> - </Switch> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={language.t("provider.connect.apiKey.label", { provider: provider().name })} - placeholder={language.t("provider.connect.apiKey.placeholder")} - name="apiKey" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - {language.t("common.submit")} - </Button> - </form> + </Match> + <Match when={store.state === "error"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-2"> + <Icon name="circle-ban-sign" class="text-icon-critical-base" /> + <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> </div> - ) - })} - </Match> - <Match when={method()?.type === "oauth"}> - <Switch> - <Match when={store.authorization?.method === "code"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", language.t("provider.connect.oauth.code.required")) - return - } - - setFormStore("error", undefined) - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - if (result.ok) { - await complete() - return - } - const message = result.error instanceof Error ? result.error.message : String(result.error) - setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) - } - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.oauth.code.visit.prefix")} - <Link href={store.authorization!.url}> - {language.t("provider.connect.oauth.code.visit.link")} - </Link> - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} - </div> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} - placeholder={language.t("provider.connect.oauth.code.placeholder")} - name="code" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - {language.t("common.submit")} - </Button> - </form> - </div> - ) - })} - </Match> - <Match when={store.authorization?.method === "auto"}> - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(() => { - void (async () => { - if (store.authorization?.url) { - platform.openLink(store.authorization.url) - } - - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - - if (!alive.value) return - - if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) - setStore("state", "error") - setStore("error", message) - return - } - - await complete() - })() - }) - - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - {language.t("provider.connect.oauth.auto.visit.prefix")} - <Link href={store.authorization!.url}> - {language.t("provider.connect.oauth.auto.visit.link")} - </Link> - {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} - </div> - <TextField - label={language.t("provider.connect.oauth.auto.confirmationCode")} - class="font-mono" - value={code()} - readOnly - copyable - /> - <div class="text-14-regular text-text-base flex items-center gap-4"> - <Spinner /> - <span>{language.t("provider.connect.status.waiting")}</span> - </div> - </div> - ) - })} - </Match> - </Switch> - </Match> - </Switch> + </div> + </Match> + <Match when={method()?.type === "api"}> + <ApiAuthView /> + </Match> + <Match when={method()?.type === "oauth"}> + <Switch> + <Match when={store.authorization?.method === "code"}> + <OAuthCodeView /> + </Match> + <Match when={store.authorization?.method === "auto"}> + <OAuthAutoView /> + </Match> + </Switch> + </Match> + </Switch> + </div> </div> </div> </Dialog> diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53773ed9e..017b85a2c 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { For } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" +type Translator = ReturnType<typeof useLanguage>["t"] + +type ModelRow = { + id: string + name: string +} + +type HeaderRow = { + key: string + value: string +} + +type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean +} + +type FormErrors = { + providerID: string | undefined + name: string | undefined + baseURL: string | undefined + models: Array<{ id?: string; name?: string }> + headers: Array<{ key?: string; value?: string }> +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set<string> +} + +function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set<string>() + const modelErrors = input.form.models.map((m) => { + const id = m.id.trim() + const modelIdError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: modelIdError, name: modelNameError } + }) + const modelsValid = modelErrors.every((m) => !m.id && !m.name) + const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set<string>() + const headerErrors = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headerErrors.every((h) => !h.key && !h.value) + const headers = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const errors: FormErrors = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + models: modelErrors, + headers: headerErrors, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { errors } + + const options = { + baseURL, + ...(Object.keys(headers).length ? { headers } : {}), + } + + return { + errors, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options, + models, + }, + }, + } +} + type Props = { back?: "providers" | "close" } @@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) { const globalSDK = useGlobalSDK() const language = useLanguage() - const [form, setForm] = createStore({ + const [form, setForm] = createStore<FormState>({ providerID: "", name: "", baseURL: "", @@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) { saving: false, }) - const [errors, setErrors] = createStore({ - providerID: undefined as string | undefined, - name: undefined as string | undefined, - baseURL: undefined as string | undefined, - models: [{} as { id?: string; name?: string }], - headers: [{} as { key?: string; value?: string }], + const [errors, setErrors] = createStore<FormErrors>({ + providerID: undefined, + name: undefined, + baseURL: undefined, + models: [{}], + headers: [{}], }) const goBack = () => { @@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { - setForm( - "models", - produce((draft) => { - draft.push({ id: "", name: "" }) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.push({}) - }), - ) + setForm("models", (v) => [...v, { id: "", name: "" }]) + setErrors("models", (v) => [...v, {}]) } const removeModel = (index: number) => { if (form.models.length <= 1) return - setForm( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("models", (v) => v.filter((_, i) => i !== index)) + setErrors("models", (v) => v.filter((_, i) => i !== index)) } const addHeader = () => { - setForm( - "headers", - produce((draft) => { - draft.push({ key: "", value: "" }) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.push({}) - }), - ) + setForm("headers", (v) => [...v, { key: "", value: "" }]) + setErrors("headers", (v) => [...v, {}]) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return - setForm( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("headers", (v) => v.filter((_, i) => i !== index)) + setErrors("headers", (v) => v.filter((_, i) => i !== index)) } const validate = () => { - const providerID = form.providerID.trim() - const name = form.name.trim() - const baseURL = form.baseURL.trim() - const apiKey = form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? language.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? language.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? language.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? language.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? language.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) - const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID) - const existsError = idError - ? undefined - : existingProvider && !disabled - ? 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 - ? language.t("provider.custom.error.required") - : seenModels.has(id) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined - return { id: modelIdError, name: modelNameError } + const output = validateCustomProvider({ + form, + t: language.t, + disabledProviders: globalSync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) - const modelsValid = modelErrors.every((m) => !m.id && !m.name) - const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set<string>() - const headerErrors = form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? language.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return 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) - const headers = Object.fromEntries( - form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - setErrors( - produce((draft) => { - draft.providerID = idError ?? existsError - draft.name = nameError - draft.baseURL = urlError - draft.models = modelErrors - draft.headers = headerErrors - }), - ) - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return - - const options = { - baseURL, - ...(Object.keys(headers).length ? { headers } : {}), - } - - return { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options, - models, - }, - } + setErrors(output.errors) + return output.result } const save = async (e: SubmitEvent) => { @@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.providerID.placeholder")} description={language.t("provider.custom.field.providerID.description")} value={form.providerID} - onChange={setForm.bind(null, "providerID")} + onChange={(v) => setForm("providerID", v)} validationState={errors.providerID ? "invalid" : undefined} error={errors.providerID} /> @@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.name.label")} placeholder={language.t("provider.custom.field.name.placeholder")} value={form.name} - onChange={setForm.bind(null, "name")} + onChange={(v) => setForm("name", v)} validationState={errors.name ? "invalid" : undefined} error={errors.name} /> @@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.baseURL.label")} placeholder={language.t("provider.custom.field.baseURL.placeholder")} value={form.baseURL} - onChange={setForm.bind(null, "baseURL")} + onChange={(v) => setForm("baseURL", v)} validationState={errors.baseURL ? "invalid" : undefined} error={errors.baseURL} /> @@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.apiKey.placeholder")} description={language.t("provider.custom.field.apiKey.description")} value={form.apiKey} - onChange={setForm.bind(null, "apiKey")} + onChange={(v) => setForm("apiKey", v)} /> </div> diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index dbad81798..ec0793c54 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) { iconHover: false, }) + let iconInput: HTMLInputElement | undefined + function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() @@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) { async function handleSubmit(e: SubmitEvent) { e.preventDefault() - setStore("saving", true) - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await globalSDK.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color, override: store.iconUrl }, - commands: { start }, - }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) - setStore("saving", false) - dialog.close() - return - } + await Promise.resolve() + .then(async () => { + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() - globalSync.project.meta(props.project.worktree, { - name, - icon: { color: store.color, override: store.iconUrl || undefined }, - commands: { start: start || undefined }, - }) - setStore("saving", false) - dialog.close() + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, + }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }) + .finally(() => { + setStore("saving", false) + }) } return ( @@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (store.iconUrl && store.iconHover) { clearIcon() } else { - document.getElementById("icon-upload")?.click() + iconInput?.click() } }} > @@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) { <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> </div> </div> - <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} /> + <input + id="icon-upload" + ref={(el) => { + iconInput = el + }} + type="file" + accept="image/*" + class="hidden" + onChange={handleInputChange} + /> <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center"> <span>{language.t("dialog.project.edit.icon.hint")}</span> <span>{language.t("dialog.project.edit.icon.recommended")}</span> diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 09d62021f..8810955cc 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" @@ -66,15 +67,23 @@ export const DialogFork: Component = () => { attachmentName: language.t("common.attachment"), }) - dialog.close() - - sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => { - if (!forked.data) return - navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) - requestAnimationFrame(() => { - prompt.set(restored) + sdk.client.session + .fork({ sessionID, messageID: item.id }) + .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + dialog.close() + navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) + requestAnimationFrame(() => { + prompt.set(restored) + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) }) - }) } return ( diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 9ee48736c..d4d4af0f1 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => { const handleConnectProvider = () => { dialog.show(() => <DialogSelectProvider />) } + const providerRank = (id: string) => popularProviders.indexOf(id) return ( <Dialog @@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => { sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + const aRank = providerRank(a.items[0].provider.id) + const bRank = providerRank(b.items[0].provider.id) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank }} onSelect={(x) => { if (!x) return - const visible = local.model.visible({ - modelID: x.id, - providerID: x.provider.id, - }) - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) + const key = { modelID: x.id, providerID: x.provider.id } + local.model.setVisibility(key, !local.model.visible(key)) }} > {(i) => ( @@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => { <span>{i.name}</span> <div onClick={(e) => e.stopPropagation()}> <Switch - checked={ - !!local.model.visible({ - modelID: i.id, - providerID: i.provider.id, - }) - } + checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })} onChange={(checked) => { local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) }} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c6f2f3930..2040009a8 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onMount, onCleanup } from "solid-js" +import { createSignal } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { handleClose() } - let focusTrap: HTMLDivElement | undefined - function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault() @@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { } } - onMount(() => { - focusTrap?.focus() - document.addEventListener("keydown", handleKeyDown) - onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - }) - - // Refocus the trap when index changes to ensure escape always works - createEffect(() => { - index() // track index - focusTrap?.focus() - }) - return ( <Dialog size="large" fit class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden" > - {/* Hidden element to capture initial focus and handle escape */} - <div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" /> - <div class="flex flex-1 min-w-0 min-h-0"> + <div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}> {/* Left side - Text content */} <div class="flex flex-col flex-1 min-w-0 p-8"> {/* Top section - feature content (fixed position from top) */} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6e7af3d90..515e640c9 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -21,157 +21,131 @@ type Row = { search: string } -export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const sync = useGlobalSync() - const sdk = useGlobalSDK() - const dialog = useDialog() - const language = useLanguage() - - const [filter, setFilter] = createSignal("") - - let list: ListRef | undefined - - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) - - const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async () => { - return sdk.client.path - .get() - .then((x) => x.data) - .catch(() => undefined) - }, - { initialValue: undefined }, - ) - - const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") - - const start = createMemo( - () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, - ) - - const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() +function cleanInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} - const clean = (value: string) => { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() - } +function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") + return v.replace(/\/+/g, "/") +} - function normalize(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") - } +function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} - function normalizeDriveRoot(input: string) { - const v = normalize(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v - } +function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} - function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") - } +function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} - function join(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r - } +function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} - function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" - } +function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v - function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) +} - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) - } +function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const +} - function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const - } +function tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" - function display(path: string, input: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} - return tildeOf(full) || full - } +function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + return tildeOf(full, home) || full +} - function tildeOf(absolute: string) { - const full = trimTrailing(absolute) - const h = home() - if (!h) return "" - - const hn = trimTrailing(h) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" +function toRow(absolute: string, home: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full, home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" } - function row(absolute: string): Row { - const full = trimTrailing(absolute) - const tilde = tildeOf(full) - - const withSlash = (value: string) => { - if (!value) return "" - if (value.endsWith("/")) return value - return value + "/" - } + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } +} - const search = Array.from( - new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), - ).join("\n") - return { absolute: full, search } - } +function useDirectorySearch(args: { + sdk: ReturnType<typeof useGlobalSDK> + start: () => string | undefined + home: () => string +}) { + const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() + let current = 0 - function scoped(value: string) { - const base = start() + const scoped = (value: string) => { + const base = args.start() if (!base) return const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } - const h = home() - if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) } + const h = args.home() + if (raw === "~") return { directory: trimTrailing(h || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } const root = rootOf(raw) if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } return { directory: trimTrailing(base), path: raw } } - async function dirs(dir: string) { + const dirs = async (dir: string) => { const key = trimTrailing(dir) const existing = cache.get(key) if (existing) return existing - const request = sdk.client.file + const request = args.sdk.client.file .list({ directory: key, path: "" }) .then((x) => x.data ?? []) .catch(() => []) @@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return request } - async function match(dir: string, query: string, limit: number) { + const match = async (dir: string, query: string, limit: number) => { const items = await dirs(dir) if (!query) return items.slice(0, limit).map((x) => x.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) } - const directories = async (filter: string) => { - const value = clean(filter) + return async (filter: string) => { + const token = ++current + const active = () => token === current + + const value = cleanInput(filter) const scopedInput = scoped(value) if (!scopedInput) return [] as string[] const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) const find = () => - sdk.client.find + args.sdk.client.find .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) if (!isPath) { const results = await find() - - return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) + if (!active()) return [] + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const branch = 4 let paths = [scopedInput.directory] for (const part of head) { + if (!active()) return [] if (part === "..") { paths = paths.map(parentOf) continue } const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() + if (!active()) return [] paths = Array.from(new Set(next)).slice(0, cap) if (paths.length === 0) return [] as string[] } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() + if (!active()) return [] const deduped = Array.from(new Set(out)) const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const expand = !raw.endsWith("/") @@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { if (!target) return deduped.slice(0, 50) const children = await match(target, "", 30) + if (!active()) return [] const items = Array.from(new Set([...deduped, ...children])) return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) } +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const sync = useGlobalSync() + const sdk = useGlobalSDK() + const dialog = useDialog() + const language = useLanguage() + + const [filter, setFilter] = createSignal("") + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = useDirectorySearch({ + sdk, + home, + start, + }) const items = async (value: string) => { const results = await directories(value) - return results.map(row) + return results.map((absolute) => toRow(absolute, home())) } function resolve(absolute: string) { @@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { key={(x) => x.absolute} filterKeys={["search"]} ref={(r) => (list = r)} - onFilter={(value) => setFilter(clean(value))} + onFilter={(value) => setFilter(cleanInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = display(item.absolute, filter()) + const value = displayPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = display(item.absolute, filter()) + const path = displayPath(item.absolute, filter(), home()) if (path === "~") { return ( <div class="w-full flex items-center justify-between rounded-md"> diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8e221577b..f35d0564c 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -36,197 +36,200 @@ type Entry = { type DialogSelectFileMode = "all" | "files" -export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const command = useCommand() - const language = useLanguage() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const params = useParams() - const navigate = useNavigate() - const globalSDK = useGlobalSDK() - const globalSync = useGlobalSync() - const filesOnly = () => props.mode === "files" - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const common = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", - ] - const limit = 5 - - const allowed = createMemo(() => { - if (filesOnly()) return [] - return command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const commandItem = (option: CommandOption): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category: language.t("palette.group.commands"), - option, - }) - - const fileItem = (path: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category: language.t("palette.group.files"), - path, - }) - - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] - - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => globalSync.data.path.home) - const label = (directory: string) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = globalSync.child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +const uniqueEntries = (items: Entry[]) => { + const seen = new Set<string>() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) } + return out +} - const sessionItem = (input: { +const createCommandEntry = (option: CommandOption, category: string): Entry => ({ + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, +}) + +const createFileEntry = (path: string, category: string): Entry => ({ + id: "file:" + path, + type: "file", + title: path, + category, + path, +}) + +const createSessionEntry = ( + input: { directory: string id: string title: string description: string archived?: number updated?: number - }): Entry => ({ - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category: language.t("command.category.session"), - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, + }, + category: string, +): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, +}) + +function createCommandEntries(props: { + filesOnly: () => boolean + command: ReturnType<typeof useCommand> + language: ReturnType<typeof useLanguage> +}) { + const allowed = createMemo(() => { + if (props.filesOnly()) return [] + return props.command.options.filter( + (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) }) - const list = createMemo(() => allowed().map(commandItem)) + const list = createMemo(() => { + const category = props.language.t("palette.group.commands") + return allowed().map((option) => createCommandEntry(option, category)) + }) const picks = createMemo(() => { const all = allowed() - const order = new Map(common.map((id, index) => [id, index])) + const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index])) const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, limit) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - return sorted.map(commandItem) + const category = props.language.t("palette.group.commands") + return sorted.map((option) => createCommandEntry(option, category)) }) + return { allowed, list, picks } +} + +function createFileEntries(props: { + file: ReturnType<typeof useFile> + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + language: ReturnType<typeof useLanguage> +}) { const recent = createMemo(() => { - const all = tabs().all() - const active = tabs().active() + const all = props.tabs().all() + const active = props.tabs().active() const order = active ? [active, ...all.filter((item) => item !== active)] : all const seen = new Set<string>() + const category = props.language.t("palette.group.files") const items: Entry[] = [] for (const item of order) { - const path = file.pathFromTab(item) + const path = props.file.pathFromTab(item) if (!path) continue if (seen.has(path)) continue seen.add(path) - items.push(fileItem(path)) + items.push(createFileEntry(path, category)) } - return items.slice(0, limit) + return items.slice(0, ENTRY_LIMIT) }) const root = createMemo(() => { - const nodes = file.tree.children("") + const category = props.language.t("palette.group.files") + const nodes = props.file.tree.children("") const paths = nodes .filter((node) => node.type === "file") .map((node) => node.path) .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, limit).map(fileItem) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) }) - const unique = (items: Entry[]) => { - const seen = new Set<string>() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) - } - return out - } + return { recent, root } +} - const sessionToken = { value: 0 } - let sessionInflight: Promise<Entry[]> | undefined - let sessionAll: Entry[] | undefined +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + globalSDK: ReturnType<typeof useGlobalSDK> + language: ReturnType<typeof useLanguage> +}) { + const state: { + token: number + inflight: Promise<Entry[]> | undefined + cached: Entry[] | undefined + } = { + token: 0, + inflight: undefined, + cached: undefined, + } const sessions = (text: string) => { const query = text.trim() if (!query) { - sessionToken.value += 1 - sessionInflight = undefined - sessionAll = undefined + state.token += 1 + state.inflight = undefined + state.cached = undefined return [] as Entry[] } - if (sessionAll) return sessionAll - if (sessionInflight) return sessionInflight + if (state.cached) return state.cached + if (state.inflight) return state.inflight - const current = sessionToken.value - const dirs = workspaces() + const current = state.token + const dirs = props.workspaces() if (dirs.length === 0) return [] as Entry[] - sessionInflight = Promise.all( + state.inflight = Promise.all( dirs.map((directory) => { - const description = label(directory) - return globalSDK.client.session + const description = props.label(directory) + return props.globalSDK.client.session .list({ directory, roots: true }) .then((x) => (x.data ?? []) .filter((s) => !!s?.id) .map((s) => ({ id: s.id, - title: s.title ?? language.t("command.session.new"), + title: s.title ?? props.language.t("command.session.new"), description, directory, archived: s.time?.archived, updated: s.time?.updated, })), ) - .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) }), ) .then((results) => { - if (sessionToken.value !== current) return [] as Entry[] + if (state.token !== current) return [] as Entry[] const seen = new Set<string>() + const category = props.language.t("command.category.session") const next = results .flat() .filter((item) => { @@ -235,18 +238,71 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil seen.add(key) return true }) - .map(sessionItem) - sessionAll = next + .map((item) => createSessionEntry(item, category)) + state.cached = next return next }) .catch(() => [] as Entry[]) .finally(() => { - sessionInflight = undefined + state.inflight = undefined }) - return sessionInflight + return state.inflight } + return { sessions } +} + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const language = useLanguage() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const filesOnly = () => props.mode === "files" + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const [grouped, setGrouped] = createSignal(false) + const commandEntries = createCommandEntries({ filesOnly, command, language }) + const fileEntries = createFileEntries({ file, tabs, language }) + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (!query && filesOnly()) { const loaded = file.tree.state("")?.loaded const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = unique([...recent(), ...root()]) + const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) if (loaded || next.length > 0) { void pending @@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } await pending - return unique([...recent(), ...root()]) + return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) } - if (!query) return [...picks(), ...recent()] + if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] if (filesOnly()) { const files = await file.searchFiles(query) - return files.map(fileItem) + const category = language.t("palette.group.files") + return files.map((path) => createFileEntry(path, category)) } const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const entries = files.map(fileItem) - return [...list(), ...nextSessions, ...entries] + const category = language.t("palette.group.files") + const entries = files.map((path) => createFileEntry(path, category)) + return [...commandEntries.list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 8eb088789..f8913eee4 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + disabled: "mcp.status.disabled", +} as const + export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() @@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => { const toggle = async (name: string) => { if (loading()) return setLoading(name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) + try { + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + } finally { + setLoading(null) } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setLoading(null) } const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) @@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => { {(i) => { const mcpStatus = () => sync.data.mcp[i.name] const status = () => mcpStatus()?.status + const statusLabel = () => { + const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined + if (!key) return + return language.t(key) + } const error = () => { const s = mcpStatus() return s?.status === "failed" ? s.error : undefined @@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => { <div class="flex flex-col gap-0.5 min-w-0"> <div class="flex items-center gap-2"> <span class="truncate">{i.name}</span> - <Show when={status() === "connected"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span> - </Show> - <Show when={status() === "failed"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span> - </Show> - <Show when={status() === "needs_auth"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span> - </Show> - <Show when={status() === "disabled"}> - <span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span> + <Show when={statusLabel()}> + <span class="text-11-regular text-text-weaker">{statusLabel()}</span> </Show> <Show when={loading() === i.name}> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 78c169777..af788d05b 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { type Component, onCleanup, onMount, Show } from "solid-js" +import { type Component, Show } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { DialogConnectProvider } from "./dialog-connect-provider" @@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => { const language = useLanguage() let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - return ( <Dialog title={language.t("dialog.model.select.title")} class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none" > - <div class="flex flex-col gap-3 px-2.5"> + <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <List class="[&_[data-slot=list-scroll]]:overflow-visible" diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 26021f06a..a196db231 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js" +import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +const isFree = (provider: string, cost: { input: number } | undefined) => + provider === "opencode" && (!cost || cost.input === 0) + const ModelList: Component<{ provider?: string class?: string @@ -54,13 +57,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - value={ - <ModelTooltip - model={item} - latest={item.latest} - free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)} - /> - } + value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} > {node} </Tooltip> @@ -75,7 +72,7 @@ const ModelList: Component<{ {(i) => ( <div class="w-full flex items-center gap-x-2 text-13-regular"> <span class="truncate">{i.name}</span> - <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> + <Show when={isFree(i.provider.id, i.cost)}> <Tag>{language.t("model.tag.free")}</Tag> </Show> <Show when={i.latest}> @@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: { const [store, setStore] = createStore<{ open: boolean dismiss: "escape" | "outside" | null - trigger?: HTMLElement - content?: HTMLElement }>({ open: false, dismiss: null, - trigger: undefined, - content: undefined, }) const dialog = useDialog() @@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: { } const language = useLanguage() - createEffect(() => { - if (!store.open) return - - const inside = (node: Node | null | undefined) => { - if (!node) return false - const el = store.content - if (el && el.contains(node)) return true - const anchor = store.trigger - if (anchor && anchor.contains(node)) return true - return false - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - setStore("dismiss", "escape") - setStore("open", false) - event.preventDefault() - event.stopPropagation() - } - - const onPointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - const onFocusIn = (event: FocusEvent) => { - if (!store.content) return - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - window.addEventListener("keydown", onKeyDown, true) - window.addEventListener("pointerdown", onPointerDown, true) - window.addEventListener("focusin", onFocusIn, true) - - onCleanup(() => { - window.removeEventListener("keydown", onKeyDown, true) - window.removeEventListener("pointerdown", onPointerDown, true) - window.removeEventListener("focusin", onFocusIn, true) - }) - }) - return ( <Kobalte open={store.open} @@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={8} > - <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> + <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} </Kobalte.Trigger> <Kobalte.Portal> <Kobalte.Content - ref={(el) => setStore("content", el)} class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" onEscapeKeyDown={(event) => { setStore("dismiss", "escape") diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index f878e50e8..8bbd3054b 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => { const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + } return ( <Dialog title={language.t("command.provider.connect")} transition> @@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => { key={(x) => x?.id} items={() => { language.locale() - return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()] + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} @@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => { <Show when={i.id === "opencode"}> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag> </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div> - </Show> - <Show when={i.id === "openai"}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div> - </Show> - <Show when={i.id.startsWith("github-copilot")}> - <div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div> - </Show> + <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show> </div> )} </List> diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 65b679f70..4c3780636 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -38,6 +38,64 @@ interface EditRowProps { onBlur: () => void } +function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) { + const [defaultUrl, defaultUrlActions] = createResource( + async () => { + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const setDefault = async (url: string | null) => { + try { + await platform.setDefaultServerUrl?.(url) + defaultUrlActions.mutate(url) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaultUrl, canDefault, setDefault } +} + +function useServerPreview(fetcher: typeof fetch) { + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkServerHealth(normalized, fetcher) + setStatus(result.healthy) + } + + return { previewStatus } +} + function AddRow(props: AddRowProps) { return ( <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> @@ -115,6 +173,10 @@ export function DialogSelectServer() { const platform = usePlatform() const globalSDK = useGlobalSDK() const language = useLanguage() + const fetcher = platform.fetch ?? globalThis.fetch + const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) + const { previewStatus } = useServerPreview(fetcher) + let listRoot: HTMLDivElement | undefined const [store, setStore] = createStore({ status: {} as Record<string, ServerHealth | undefined>, addServer: { @@ -132,43 +194,6 @@ export function DialogSelectServer() { status: undefined as boolean | undefined, }, }) - const [defaultUrl, defaultUrlActions] = createResource( - async () => { - try { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - return null - } - }, - { initialValue: null }, - ) - const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) - const fetcher = platform.fetch ?? globalThis.fetch - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const result = await checkServerHealth(normalized, fetcher) - setStatus(result.healthy) - } const resetAdd = () => { setStore("addServer", { @@ -263,7 +288,7 @@ export function DialogSelectServer() { } const scrollListToBottom = () => { - const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]') + const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]') if (!scroll) return requestAnimationFrame(() => { scroll.scrollTop = scroll.scrollHeight @@ -363,158 +388,134 @@ export function DialogSelectServer() { return ( <Dialog title={language.t("dialog.server.title")}> <div class="flex flex-col gap-2"> - <List - search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} - noInitialSelection - emptyMessage={language.t("dialog.server.empty")} - items={sortedItems} - key={(x) => x} - onSelect={(x) => { - if (x) select(x) - }} - onFilter={(value) => { - if (value && store.addServer.showForm && !store.addServer.adding) { - resetAdd() + <div ref={(el) => (listRoot = el)}> + <List + search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} + noInitialSelection + emptyMessage={language.t("dialog.server.empty")} + items={sortedItems} + key={(x) => x} + onSelect={(x) => { + if (x) select(x) + }} + onFilter={(value) => { + if (value && store.addServer.showForm && !store.addServer.adding) { + resetAdd() + } + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" + add={ + store.addServer.showForm + ? { + render: () => ( + <AddRow + value={store.addServer.url} + placeholder={language.t("dialog.server.add.placeholder")} + adding={store.addServer.adding} + error={store.addServer.error} + status={store.addServer.status} + onChange={handleAddChange} + onKeyDown={handleAddKey} + onBlur={blurAdd} + /> + ), + } + : undefined } - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" - add={ - store.addServer.showForm - ? { - render: () => ( - <AddRow - value={store.addServer.url} - placeholder={language.t("dialog.server.add.placeholder")} - adding={store.addServer.adding} - error={store.addServer.error} - status={store.addServer.status} - onChange={handleAddChange} - onKeyDown={handleAddKey} - onBlur={blurAdd} - /> - ), - } - : undefined - } - > - {(i) => { - return ( - <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> - <Show - when={store.editServer.id !== i} - fallback={ - <EditRow - value={store.editServer.value} - placeholder={language.t("dialog.server.add.placeholder")} - busy={store.editServer.busy} - error={store.editServer.error} - status={store.editServer.status} - onChange={handleEditChange} - onKeyDown={(event) => handleEditKey(event, i)} - onBlur={() => handleEdit(i, store.editServer.value)} + > + {(i) => { + return ( + <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> + <Show + when={store.editServer.id !== i} + fallback={ + <EditRow + value={store.editServer.value} + placeholder={language.t("dialog.server.add.placeholder")} + busy={store.editServer.busy} + error={store.editServer.error} + status={store.editServer.status} + onChange={handleEditChange} + onKeyDown={(event) => handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > + <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> + } /> - } - > - <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> + <Show when={store.editServer.id !== i}> + <div class="flex items-center justify-center gap-5 pl-4"> + <Show when={current() === i}> + <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> </Show> - } - /> - </Show> - <Show when={store.editServer.id !== i}> - <div class="flex items-center justify-center gap-5 pl-4"> - <Show when={current() === i}> - <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> - </Show> - - <DropdownMenu> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" - onClick={(e: MouseEvent) => e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content class="mt-1"> - <DropdownMenu.Item - onSelect={() => { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) - }} - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <Show when={canDefault() && defaultUrl() !== i}> + + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" + onClick={(e: MouseEvent) => e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content class="mt-1"> <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } + onSelect={() => { + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) }} > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.default")} - </DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <Show when={canDefault() && defaultUrl() === i}> + <Show when={canDefault() && defaultUrl() !== i}> + <DropdownMenu.Item onSelect={() => setDefault(i)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.default")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <Show when={canDefault() && defaultUrl() === i}> + <DropdownMenu.Item onSelect={() => setDefault(null)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.defaultRemove")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <DropdownMenu.Separator /> <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - }} + onSelect={() => handleRemove(i)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.defaultRemove")} - </DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <DropdownMenu.Separator /> - <DropdownMenu.Item - onSelect={() => handleRemove(i)} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - </Show> - </div> - ) - }} - </List> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + </Show> + </div> + ) + }} + </List> + </div> <div class="px-5 pb-5"> <Button diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index f8892ebbd..83cea131f 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -67,15 +67,6 @@ export const DialogSettings: Component = () => { <Tabs.Content value="models" class="no-scrollbar"> <SettingsModels /> </Tabs.Content> - {/* <Tabs.Content value="agents" class="no-scrollbar"> */} - {/* <SettingsAgents /> */} - {/* </Tabs.Content> */} - {/* <Tabs.Content value="commands" class="no-scrollbar"> */} - {/* <SettingsCommands /> */} - {/* </Tabs.Content> */} - {/* <Tabs.Content value="mcp" class="no-scrollbar"> */} - {/* <SettingsMcp /> */} - {/* </Tabs.Content> */} </Tabs> </Dialog> ) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d7b729973..5552cc90b 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -15,6 +15,7 @@ import { Switch, untrack, type ComponentProps, + type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -59,6 +60,189 @@ export function dirsToExpand(input: { return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) } +const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +const kindTextColor = (kind: Kind) => { + if (kind === "add") return "color: var(--icon-diff-add-base)" + if (kind === "del") return "color: var(--icon-diff-delete-base)" + return "color: var(--icon-warning-active)" +} + +const kindDotColor = (kind: Kind) => { + if (kind === "add") return "background-color: var(--icon-diff-add-base)" + if (kind === "del") return "background-color: var(--icon-diff-delete-base)" + return "background-color: var(--icon-warning-active)" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNode = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap<string, Kind> + marks?: Set<string> + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + const active = () => !!kind() && !local.node.ignored + const color = () => { + const value = kind() + if (!value) return + return kindTextColor(value) + } + + return ( + <Dynamic + component={local.as ?? "div"} + classList={{ + "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, + "bg-surface-base-active": local.node.path === local.active, + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + [local.nodeClass ?? ""]: !!local.nodeClass, + }} + style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`} + draggable={local.draggable} + onDragStart={(event: DragEvent) => { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + <span + classList={{ + "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true, + "text-text-weaker": local.node.ignored, + "text-text-weak": !local.node.ignored && !active(), + }} + style={active() ? color() : undefined} + > + {local.node.name} + </span> + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}> + {kindLabel(value)} + </span> + ) + } + return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} /> + })()} + </Dynamic> + ) +} + +const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => { + if (!props.enabled) return props.children + + const parts = props.node.path.split("/") + const leaf = parts[parts.length - 1] ?? props.node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + const label = + props.kind === "add" + ? "Additions" + : props.kind === "del" + ? "Deletions" + : props.kind === "mix" + ? "Modifications" + : undefined + + return ( + <Tooltip + openDelay={2000} + placement="bottom-start" + class="w-full" + contentStyle={{ "max-width": "480px", width: "fit-content" }} + value={ + <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular"> + <span + class="min-w-0 truncate text-text-invert-base" + style={{ direction: "rtl", "unicode-bidi": "plaintext" }} + > + {prefix} + </span> + <span class="shrink-0 text-text-invert-strong">{leaf}</span> + <Show when={label}> + {(text) => ( + <> + <span class="mx-1 font-bold text-text-invert-strong">•</span> + <span class="shrink-0 text-text-invert-strong">{text()}</span> + </> + )} + </Show> + <Show when={props.node.type === "directory" && props.node.ignored}> + <> + <span class="mx-1 font-bold text-text-invert-strong">•</span> + <span class="shrink-0 text-text-invert-strong">Ignored</span> + </> + </Show> + </div> + } + > + {props.children} + </Tooltip> + ) +} + export default function FileTree(props: { path: string class?: string @@ -230,178 +414,13 @@ export default function FileTree(props: { return out }) - const Node = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - as?: "div" | "button" - }, - ) => { - const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) - return ( - <Dynamic - component={local.as ?? "div"} - classList={{ - "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, - "bg-surface-base-active": local.node.path === props.active, - ...(local.classList ?? {}), - [local.class ?? ""]: !!local.class, - [props.nodeClass ?? ""]: !!props.nodeClass, - }} - style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`} - draggable={draggable()} - onDragStart={(e: DragEvent) => { - if (!draggable()) return - e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" - - const dragImage = document.createElement("div") - dragImage.className = - "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" - dragImage.style.position = "absolute" - dragImage.style.top = "-1000px" - - const icon = - (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? - (e.currentTarget as HTMLElement).querySelector("svg") - const text = (e.currentTarget as HTMLElement).querySelector("span") - if (icon && text) { - dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML - } - - document.body.appendChild(dragImage) - e.dataTransfer?.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...rest} - > - {local.children} - {(() => { - const kind = kinds()?.get(local.node.path) - const marked = marks()?.has(local.node.path) ?? false - const active = !!kind && marked && !local.node.ignored - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : kind === "mix" - ? "color: var(--icon-warning-active)" - : undefined - return ( - <span - classList={{ - "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true, - "text-text-weaker": local.node.ignored, - "text-text-weak": !local.node.ignored && !active, - }} - style={active ? color : undefined} - > - {local.node.name} - </span> - ) - })()} - {(() => { - const kind = kinds()?.get(local.node.path) - if (!kind) return null - if (!marks()?.has(local.node.path)) return null - - if (local.node.type === "file") { - const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : "color: var(--icon-warning-active)" - - return ( - <span class="shrink-0 w-4 text-center text-12-medium" style={color}> - {text} - </span> - ) - } - - if (local.node.type === "directory") { - const color = - kind === "add" - ? "background-color: var(--icon-diff-add-base)" - : kind === "del" - ? "background-color: var(--icon-diff-delete-base)" - : "background-color: var(--icon-warning-active)" - - return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} /> - } - - return null - })()} - </Dynamic> - ) - } - return ( <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <For each={nodes()}> {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false const deep = () => deeps().get(node.path) ?? -1 - const Wrapper = (p: ParentProps) => { - if (!tooltip()) return p.children - - const parts = node.path.split("/") - const leaf = parts[parts.length - 1] ?? node.path - const head = parts.slice(0, -1).join("/") - const prefix = head ? `${head}/` : "" - - const kind = () => kinds()?.get(node.path) - const label = () => { - const k = kind() - if (!k) return - if (k === "add") return "Additions" - if (k === "del") return "Deletions" - return "Modifications" - } - - const ignored = () => node.type === "directory" && node.ignored - - return ( - <Tooltip - openDelay={2000} - placement="bottom-start" - class="w-full" - contentStyle={{ "max-width": "480px", width: "fit-content" }} - value={ - <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular"> - <span - class="min-w-0 truncate text-text-invert-base" - style={{ direction: "rtl", "unicode-bidi": "plaintext" }} - > - {prefix} - </span> - <span class="shrink-0 text-text-invert-strong">{leaf}</span> - <Show when={label()}> - {(t: () => string) => ( - <> - <span class="mx-1 font-bold text-text-invert-strong">•</span> - <span class="shrink-0 text-text-invert-strong">{t()}</span> - </> - )} - </Show> - <Show when={ignored()}> - <> - <span class="mx-1 font-bold text-text-invert-strong">•</span> - <span class="shrink-0 text-text-invert-strong">Ignored</span> - </> - </Show> - </div> - } - > - {p.children} - </Tooltip> - ) - } + const kind = () => visibleKind(node, kinds(), marks()) return ( <Switch> @@ -415,13 +434,21 @@ export default function FileTree(props: { onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > <Collapsible.Trigger> - <Wrapper> - <Node node={node}> + <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> + <FileTreeNode + node={node} + level={level} + active={props.active} + nodeClass={props.nodeClass} + draggable={draggable()} + kinds={kinds()} + marks={marks()} + > <div class="size-4 flex items-center justify-center text-icon-weak"> <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" /> </div> - </Node> - </Wrapper> + </FileTreeNode> + </FileTreeNodeTooltip> </Collapsible.Trigger> <Collapsible.Content class="relative pt-0.5"> <div @@ -451,12 +478,23 @@ export default function FileTree(props: { </Collapsible> </Match> <Match when={node.type === "file"}> - <Wrapper> - <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}> + <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> + <FileTreeNode + node={node} + level={level} + active={props.active} + nodeClass={props.nodeClass} + draggable={draggable()} + kinds={kinds()} + marks={marks()} + as="button" + type="button" + onClick={() => props.onFileClick?.(node)} + > <div class="w-4 shrink-0" /> <FileIcon node={node} class="text-icon-weak size-4" /> - </Node> - </Wrapper> + </FileTreeNode> + </FileTreeNodeTooltip> </Match> </Switch> ) diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx index e13c31330..85f7efc53 100644 --- a/packages/app/src/components/link.tsx +++ b/packages/app/src/components/link.tsx @@ -1,17 +1,26 @@ import { ComponentProps, splitProps } from "solid-js" import { usePlatform } from "@/context/platform" -export interface LinkProps extends ComponentProps<"button"> { +export interface LinkProps extends Omit<ComponentProps<"a">, "href"> { href: string } export function Link(props: LinkProps) { const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children"]) + const [local, rest] = splitProps(props, ["href", "children", "class"]) return ( - <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}> + <a + href={local.href} + class={`text-text-strong underline ${local.class ?? ""}`} + onClick={(event) => { + if (!local.href) return + event.preventDefault() + platform.openLink(local.href) + }} + {...rest} + > {local.children} - </button> + </a> ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 4f495d27d..d591b22c7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) + const closePopover = () => setStore("popover", null) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + createEffect(() => { params.id if (params.id) return @@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 createEffect(() => { - if (!isFocused()) setStore("popover", null) + if (!isFocused()) closePopover() }) // Safety: reset composing state on focus change to prevent stuck state @@ -381,26 +422,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - setStore("popover", null) + closePopover() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` - editorRef.innerHTML = "" - editorRef.textContent = text + setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const sel = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - sel?.removeAllRanges() - sel?.addRange(range) - }) + focusEditorEnd() return } - editorRef.innerHTML = "" + clearEditor() prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") } @@ -454,7 +486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }) const renderEditor = (parts: Prompt) => { - editorRef.innerHTML = "" + clearEditor() for (const part of parts) { if (part.type === "text") { editorRef.appendChild(createTextFragment(part.content)) @@ -514,34 +546,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { mirror.input = false if (isNormalizedEditor()) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) return } const domParts = parseFromDOM() if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) }, ), ) @@ -636,11 +648,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 if (shouldReset) { - setStore("popover", null) - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + closePopover() + resetHistoryNavigation() if (prompt.dirty()) { mirror.input = true prompt.set(DEFAULT_PROMPT, 0) @@ -662,16 +671,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { - setStore("popover", null) + closePopover() } } else { - setStore("popover", null) + closePopover() } - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + resetHistoryNavigation() mirror.input = true prompt.set([...rawParts, ...images], cursorPosition) @@ -732,7 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } handleInput() - setStore("popover", null) + closePopover() } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -782,8 +788,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { promptLength, addToHistory, resetHistoryNavigation: () => { - setStore("historyIndex", -1) - setStore("savedPrompt", null) + resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), @@ -872,7 +877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (ctrl && event.code === "KeyG") { if (store.popover) { - setStore("popover", null) + closePopover() event.preventDefault() return } @@ -923,7 +928,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } if (event.key === "Escape") { if (store.popover) { - setStore("popover", null) + closePopover() } else if (working()) { abort() } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index a843e109d..b575c3961 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => { <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)} + {(item) => { + const directory = getDirectory(item.path) + const filename = getFilename(item.path) + const label = getFilenameTruncated(item.path, 14) + const selected = props.active(item) + + return ( + <Tooltip + value={ + <span class="flex max-w-[300px]"> + <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> + {directory} + </span> + <span class="shrink-0">{filename}</span> </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)} + } + placement="top" + openDelay={2000} > - <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 + 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 && !selected, + "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": + selected, + "bg-background-stronger": !selected, + }} + 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">{label}</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> - <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")} - /> + <Show when={item.comment}> + {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} + </Show> </div> - <Show when={item.comment}> - {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} - </Show> - </div> - </Tooltip> - )} + </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 index e05b47d7c..41962ce53 100644 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -6,12 +6,17 @@ type PromptDragOverlayProps = { label: string } +const kindToIcon = { + image: "photo", + "@mention": "link", +} as const + export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => { return ( <Show when={props.type !== null}> <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={props.type === "@mention" ? "link" : "photo"} class="size-8" /> + <Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" /> <span class="text-14-regular">{props.label}</span> </div> </div> diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index ba3addf0a..835fddc30 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = { removeLabel: string } +const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" +const imageClass = + "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const removeClass = + "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" +const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" + export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => { return ( <Show when={props.attachments.length > 0}> @@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p <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"> + <div class={fallbackClass}> <Icon name="folder" class="size-6 text-text-weak" /> </div> } @@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p <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" + class={imageClass} 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" + class={removeClass} 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"> + <div class={nameClass}> <span class="text-10-regular text-white truncate block">{attachment.filename}</span> </div> </div> diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index b97bb6752..554a15bb7 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -52,47 +52,46 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => { 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> - </> - } + {(item) => { + const active = props.atActive === props.atKey(item) + const shared = { + "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, + "bg-surface-raised-base-hover": active, + } + + if (item.type === "agent") { + return ( + <button + classList={shared} + onClick={() => props.onAtSelect(item)} + onMouseEnter={() => props.setAtActive(props.atKey(item))} + > + <Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> + <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span> + </button> + ) + } + + const isDirectory = item.path.endsWith("/") + const directory = isDirectory ? item.path : getDirectory(item.path) + const filename = isDirectory ? "" : getFilename(item.path) + + return ( + <button + classList={shared} + onClick={() => props.onAtSelect(item)} + onMouseEnter={() => props.setAtActive(props.atKey(item))} > - <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> - )} + <FileIcon node={{ path: 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">{directory}</span> + <Show when={!isDirectory}> + <span class="text-text-strong whitespace-nowrap">{filename}</span> + </Show> + </div> + </button> + ) + }} </For> </Show> </Match> diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index f626fcc9b..1ab184535 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +const writeAt = <T,>(list: T[], index: number, value: T) => { + const next = [...list] + next[index] = value + return next +} + +const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => { + return writeAt(list, index, [value]) +} + +const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value] + return writeAt(list, index, next) +} + +const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + if (current.includes(value)) return list + return writeAt(list, index, [...current, value]) +} + +const writeCustom = (list: string[], index: number, value: string) => { + return writeAt(list, index, value) +} + export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => showToast({ title: language.t("common.requestFailed"), description: message }) } - const reply = (answers: QuestionAnswer[]) => { + const reply = async (answers: QuestionAnswer[]) => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reply({ requestID: props.request.id, answers }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reply({ requestID: props.request.id, answers }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } - const reject = () => { + const reject = async () => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reject({ requestID: props.request.id }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reject({ requestID: props.request.id }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } const submit = () => { - reply(questions().map((_, i) => store.answers[i] ?? [])) + void reply(questions().map((_, i) => store.answers[i] ?? [])) } const pick = (answer: string, custom: boolean = false) => { - const answers = [...store.answers] - answers[store.tab] = [answer] - setStore("answers", answers) + setStore("answers", pickAnswer(store.answers, store.tab, answer)) if (custom) { - const inputs = [...store.custom] - inputs[store.tab] = answer - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, answer)) } if (single()) { - reply([[answer]]) + void reply([[answer]]) return } @@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } const toggle = (answer: string) => { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - const index = next.indexOf(answer) - if (index === -1) next.push(answer) - if (index !== -1) next.splice(index, 1) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", toggleAnswer(store.answers, store.tab, answer)) } const selectTab = (index: number) => { @@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } if (multi()) { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (!next.includes(value)) next.push(value) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", appendAnswer(store.answers, store.tab, value)) setStore("editing", false) return } @@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => value={input()} disabled={store.sending} onInput={(e) => { - const inputs = [...store.custom] - inputs[store.tab] = e.currentTarget.value - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value)) }} /> <Button type="submit" variant="primary" size="small" disabled={store.sending}> diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index b43c07882..f93bdb33b 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -1,5 +1,5 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" -import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { serverDisplayName } from "@/context/server" import type { ServerHealth } from "@/utils/server-health" @@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) { const [truncated, setTruncated] = createSignal(false) let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined + const name = createMemo(() => serverDisplayName(props.url)) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) { } createEffect(() => { + name() props.url props.status?.version - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(check) - return - } - check() + queueMicrotask(check) }) onMount(() => { check() - if (typeof window === "undefined") return - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) + if (typeof ResizeObserver !== "function") return + const observer = new ResizeObserver(check) + if (nameRef) observer.observe(nameRef) + if (versionRef) observer.observe(versionRef) + onCleanup(() => observer.disconnect()) }) const tooltipValue = () => ( <span class="flex items-center gap-2"> - <span>{serverDisplayName(props.url)}</span> + <span>{name()}</span> <Show when={props.status?.version}> <span class="text-text-invert-base">{props.status?.version}</span> </Show> @@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) { }} /> <span ref={nameRef} class={props.nameClass ?? "truncate"}> - {serverDisplayName(props.url)} + {name()} </span> <Show when={props.status?.version}> <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 4e5dae139..8b77edf3a 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -13,6 +13,18 @@ interface SessionContextUsageProps { variant?: "button" | "indicator" } +function openSessionContext(args: { + view: ReturnType<ReturnType<typeof useLayout>["view"]> + layout: ReturnType<typeof useLayout> + tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]> +}) { + if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() + args.layout.fileTree.open() + args.layout.fileTree.setTab("all") + args.tabs.open("context") + args.tabs.setActive("context") +} + export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() const params = useParams() @@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.open() - layout.fileTree.setTab("all") - tabs().open("context") - tabs().setActive("context") + openSessionContext({ + view: view(), + layout, + tabs: tabs(), + }) } const circle = () => ( diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts new file mode 100644 index 000000000..f38aecb55 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { estimateSessionContextBreakdown } from "./session-context-breakdown" + +const user = (id: string) => { + return { + id, + role: "user", + time: { created: 1 }, + } as unknown as Message +} + +const assistant = (id: string) => { + return { + id, + role: "assistant", + time: { created: 1 }, + } as unknown as Message +} + +describe("estimateSessionContextBreakdown", () => { + test("estimates tokens and keeps remaining tokens as other", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "hello world" }] as unknown as Part[], + a1: [{ type: "text", text: "assistant response" }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 20, + systemPrompt: "system prompt", + }) + + const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens])) + expect(map.system).toBe(4) + expect(map.user).toBe(3) + expect(map.assistant).toBe(5) + expect(map.other).toBe(8) + }) + + test("scales segments when estimates exceed input", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[], + a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 10, + systemPrompt: "z".repeat(200), + }) + + const total = output.reduce((sum, segment) => sum + segment.tokens, 0) + expect(total).toBeLessThanOrEqual(10) + expect(output.every((segment) => segment.width <= 100)).toBeTrue() + }) +}) diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts new file mode 100644 index 000000000..e263b2957 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -0,0 +1,132 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" + +export type SessionContextBreakdownSegment = { + key: SessionContextBreakdownKey + tokens: number + width: number + percent: number +} + +const estimateTokens = (chars: number) => Math.ceil(chars / 4) +const toPercent = (tokens: number, input: number) => (tokens / input) * 100 +const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 + +const charsFromUserPart = (part: Part) => { + if (part.type === "text") return part.text.length + if (part.type === "file") return part.source?.text.value.length ?? 0 + if (part.type === "agent") return part.source?.value.length ?? 0 + return 0 +} + +const charsFromAssistantPart = (part: Part) => { + if (part.type === "text") return { assistant: part.text.length, tool: 0 } + if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } + if (part.type !== "tool") return { assistant: 0, tool: 0 } + + const input = Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length } + if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length } + if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length } + return { assistant: 0, tool: input } +} + +const build = ( + tokens: { system: number; user: number; assistant: number; tool: number; other: number }, + input: number, +) => { + return [ + { + key: "system", + tokens: tokens.system, + }, + { + key: "user", + tokens: tokens.user, + }, + { + key: "assistant", + tokens: tokens.assistant, + }, + { + key: "tool", + tokens: tokens.tool, + }, + { + key: "other", + tokens: tokens.other, + }, + ] + .filter((x) => x.tokens > 0) + .map((x) => ({ + key: x.key, + tokens: x.tokens, + width: toPercent(x.tokens, input), + percent: toPercentLabel(x.tokens, input), + })) as SessionContextBreakdownSegment[] +} + +export function estimateSessionContextBreakdown(args: { + messages: Message[] + parts: Record<string, Part[] | undefined> + input: number + systemPrompt?: string +}) { + if (!args.input) return [] + + const counts = args.messages.reduce( + (acc, msg) => { + const parts = args.parts[msg.id] ?? [] + if (msg.role === "user") { + const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0) + return { ...acc, user: acc.user + user } + } + + if (msg.role !== "assistant") return acc + const assistant = parts.reduce( + (sum, part) => { + const next = charsFromAssistantPart(part) + return { + assistant: sum.assistant + next.assistant, + tool: sum.tool + next.tool, + } + }, + { assistant: 0, tool: 0 }, + ) + return { + ...acc, + assistant: acc.assistant + assistant.assistant, + tool: acc.tool + assistant.tool, + } + }, + { + system: args.systemPrompt?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + }, + ) + + const tokens = { + system: estimateTokens(counts.system), + user: estimateTokens(counts.user), + assistant: estimateTokens(counts.assistant), + tool: estimateTokens(counts.tool), + } + const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool + + if (estimated <= args.input) { + return build({ ...tokens, other: args.input - estimated }, args.input) + } + + const scale = args.input / estimated + const scaled = { + system: Math.floor(tokens.system * scale), + user: Math.floor(tokens.user * scale), + assistant: Math.floor(tokens.assistant * scale), + tool: Math.floor(tokens.tool * scale), + } + const total = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input) +} diff --git a/packages/app/src/components/session/session-context-format.ts b/packages/app/src/components/session/session-context-format.ts new file mode 100644 index 000000000..e7c536d58 --- /dev/null +++ b/packages/app/src/components/session/session-context-format.ts @@ -0,0 +1,20 @@ +import { DateTime } from "luxon" + +export function createSessionContextFormatter(locale: string) { + return { + number(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + }, + percent(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + "%" + }, + time(value: number | undefined) { + if (!value) return "—" + return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED) + }, + } +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 8aae44863..eb5b4197d 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,7 +1,6 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useParams } from "@solidjs/router" -import { DateTime } from "luxon" import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" @@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "./session-context-metrics" +import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" +import { createSessionContextFormatter } from "./session-context-format" interface SessionContextTabProps { messages: () => Message[] @@ -22,6 +23,74 @@ interface SessionContextTabProps { info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> } +const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { + system: "var(--syntax-info)", + user: "var(--syntax-success)", + assistant: "var(--syntax-property)", + tool: "var(--syntax-warning)", + other: "var(--syntax-comment)", +} + +function Stat(props: { label: string; value: JSX.Element }) { + return ( + <div class="flex flex-col gap-1"> + <div class="text-12-regular text-text-weak">{props.label}</div> + <div class="text-12-medium text-text-strong">{props.value}</div> + </div> + ) +} + +function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { + const file = createMemo(() => { + const parts = props.getParts(props.message.id) + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return ( + <Code + file={file()} + overflow="wrap" + class="select-text" + onRendered={() => requestAnimationFrame(props.onRendered)} + /> + ) +} + +function RawMessage(props: { + message: Message + getParts: (id: string) => Part[] + onRendered: () => void + time: (value: number | undefined) => string +}) { + return ( + <Accordion.Item value={props.message.id}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div class="flex items-center justify-between gap-2 w-full"> + <div class="min-w-0 truncate"> + {props.message.role} <span class="text-text-base">• {props.message.id}</span> + </div> + <div class="flex items-center gap-3"> + <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div> + <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" /> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content class="bg-background-base"> + <div class="p-3"> + <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} /> + </div> + </Accordion.Content> + </Accordion.Item> + ) +} + export function SessionContextTab(props: SessionContextTabProps) { const params = useParams() const sync = useSync() @@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) { const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const ctx = createMemo(() => metrics().context) + const formatter = createMemo(() => createSessionContextFormatter(language.locale())) const cost = createMemo(() => { return usd().format(metrics().totalCost) @@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) { return trimmed }) - const number = (value: number | null | undefined) => { - if (value === undefined) return "—" - if (value === null) return "—" - return value.toLocaleString(language.locale()) - } - - const percent = (value: number | null | undefined) => { - if (value === undefined) return "—" - if (value === null) return "—" - return value.toLocaleString(language.locale()) + "%" - } - - const time = (value: number | undefined) => { - if (!value) return "—" - return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED) - } - const providerLabel = createMemo(() => { const c = ctx() if (!c) return "—" @@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) { () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => { const c = ctx() - if (!c) return [] - const input = c.input - if (!input) return [] - - const out = { - system: systemPrompt()?.length ?? 0, - user: 0, - assistant: 0, - tool: 0, - } - - for (const msg of props.messages()) { - const parts = (sync.data.part[msg.id] ?? []) as Part[] - - if (msg.role === "user") { - for (const part of parts) { - if (part.type === "text") out.user += part.text.length - if (part.type === "file") out.user += part.source?.text.value.length ?? 0 - if (part.type === "agent") out.user += part.source?.value.length ?? 0 - } - continue - } - - if (msg.role === "assistant") { - for (const part of parts) { - if (part.type === "text") out.assistant += part.text.length - if (part.type === "reasoning") out.assistant += part.text.length - if (part.type === "tool") { - out.tool += Object.keys(part.state.input).length * 16 - if (part.state.status === "pending") out.tool += part.state.raw.length - if (part.state.status === "completed") out.tool += part.state.output.length - if (part.state.status === "error") out.tool += part.state.error.length - } - } - } - } - - const estimateTokens = (chars: number) => Math.ceil(chars / 4) - const system = estimateTokens(out.system) - const user = estimateTokens(out.user) - const assistant = estimateTokens(out.assistant) - const tool = estimateTokens(out.tool) - const estimated = system + user + assistant + tool - - const pct = (tokens: number) => (tokens / input) * 100 - const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%" - - const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => { - return [ - { - key: "system", - label: language.t("context.breakdown.system"), - tokens: tokens.system, - width: pct(tokens.system), - percent: pctLabel(tokens.system), - color: "var(--syntax-info)", - }, - { - key: "user", - label: language.t("context.breakdown.user"), - tokens: tokens.user, - width: pct(tokens.user), - percent: pctLabel(tokens.user), - color: "var(--syntax-success)", - }, - { - key: "assistant", - label: language.t("context.breakdown.assistant"), - tokens: tokens.assistant, - width: pct(tokens.assistant), - percent: pctLabel(tokens.assistant), - color: "var(--syntax-property)", - }, - { - key: "tool", - label: language.t("context.breakdown.tool"), - tokens: tokens.tool, - width: pct(tokens.tool), - percent: pctLabel(tokens.tool), - color: "var(--syntax-warning)", - }, - { - key: "other", - label: language.t("context.breakdown.other"), - tokens: tokens.other, - width: pct(tokens.other), - percent: pctLabel(tokens.other), - color: "var(--syntax-comment)", - }, - ].filter((x) => x.tokens > 0) - } - - if (estimated <= input) { - return build({ system, user, assistant, tool, other: input - estimated }) - } - - const scale = input / estimated - const scaled = { - system: Math.floor(system * scale), - user: Math.floor(user * scale), - assistant: Math.floor(assistant * scale), - tool: Math.floor(tool * scale), - } - const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool - return build({ ...scaled, other: Math.max(0, input - scaledTotal) }) + if (!c?.input) return [] + return estimateSessionContextBreakdown({ + messages: props.messages(), + parts: sync.data.part as Record<string, Part[] | undefined>, + input: c.input, + systemPrompt: systemPrompt(), + }) }, ), ) - function Stat(statProps: { label: string; value: JSX.Element }) { - return ( - <div class="flex flex-col gap-1"> - <div class="text-12-regular text-text-weak">{statProps.label}</div> - <div class="text-12-medium text-text-strong">{statProps.value}</div> - </div> - ) + const breakdownLabel = (key: SessionContextBreakdownKey) => { + if (key === "system") return language.t("context.breakdown.system") + if (key === "user") return language.t("context.breakdown.user") + if (key === "assistant") return language.t("context.breakdown.assistant") + if (key === "tool") return language.t("context.breakdown.tool") + return language.t("context.breakdown.other") } const stats = createMemo(() => { @@ -222,15 +176,15 @@ export function SessionContextTab(props: SessionContextTabProps) { { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) }, { label: language.t("context.stats.provider"), value: providerLabel() }, { label: language.t("context.stats.model"), value: modelLabel() }, - { label: language.t("context.stats.limit"), value: number(c?.limit) }, - { label: language.t("context.stats.totalTokens"), value: number(c?.total) }, - { label: language.t("context.stats.usage"), value: percent(c?.usage) }, - { label: language.t("context.stats.inputTokens"), value: number(c?.input) }, - { label: language.t("context.stats.outputTokens"), value: number(c?.output) }, - { label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) }, + { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) }, + { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) }, + { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) }, + { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) }, + { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) }, + { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) }, { label: language.t("context.stats.cacheTokens"), - value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`, + value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`, }, { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) }, { @@ -238,55 +192,15 @@ export function SessionContextTab(props: SessionContextTabProps) { value: count.assistant.toLocaleString(language.locale()), }, { label: language.t("context.stats.totalCost"), value: cost() }, - { label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) }, - { label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) }, + { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) }, + { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) }, ] satisfies { label: string; value: JSX.Element }[] }) - function RawMessageContent(msgProps: { message: Message }) { - const file = createMemo(() => { - const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[] - const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2) - return { - name: `${msgProps.message.role}-${msgProps.message.id}.json`, - contents, - cacheKey: checksum(contents), - } - }) - - return ( - <Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} /> - ) - } - - function RawMessage(msgProps: { message: Message }) { - return ( - <Accordion.Item value={msgProps.message.id}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div class="flex items-center justify-between gap-2 w-full"> - <div class="min-w-0 truncate"> - {msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span> - </div> - <div class="flex items-center gap-3"> - <div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div> - <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" /> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content class="bg-background-base"> - <div class="p-3"> - <RawMessageContent message={msgProps.message} /> - </div> - </Accordion.Content> - </Accordion.Item> - ) - } - let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined + const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[] const restoreScroll = () => { const el = scroll @@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) { class="h-full" style={{ width: `${segment.width}%`, - "background-color": segment.color, + "background-color": BREAKDOWN_COLOR[segment.key], }} /> )} @@ -366,9 +280,9 @@ export function SessionContextTab(props: SessionContextTabProps) { <For each={breakdown()}> {(segment) => ( <div class="flex items-center gap-1 text-11-regular text-text-weak"> - <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} /> - <div>{segment.label}</div> - <div class="text-text-weaker">{segment.percent}</div> + <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} /> + <div>{breakdownLabel(segment.key)}</div> + <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div> </div> )} </For> @@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) { <div class="flex flex-col gap-2"> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <Accordion multiple> - <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For> + <For each={props.messages()}> + {(message) => ( + <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> + )} + </For> </Accordion> </div> </div> diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 54e24a6fb..c1468ce37 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" +const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +type OpenApp = (typeof OPEN_APPS)[number] +type OS = "macos" | "windows" | "linux" | "unknown" + +const MAC_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +const WINDOWS_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +const LINUX_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, +] as const + +type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] +type OpenIcon = OpenApp | "file-explorer" +const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"]) + +const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]") + +const detectOS = (platform: ReturnType<typeof usePlatform>): OS => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useSessionShare(args: { + globalSDK: ReturnType<typeof useGlobalSDK> + currentSession: () => + | { + id: string + share?: { + url?: string + } + } + | undefined + projectDirectory: () => string + platform: ReturnType<typeof usePlatform> +}) { + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => args.currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + const shareSession = () => { + const session = args.currentSession() + if (!session || state.share) return + setState("share", true) + args.globalSDK.client.session + .share({ sessionID: session.id, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + const unshareSession = () => { + const session = args.currentSession() + if (!session || state.unshare) return + setState("unshare", true) + args.globalSDK.client.session + .unshare({ sessionID: session.id, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + const copyLink = (onError: (error: unknown) => void) => { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch(onError) + } + + const viewShare = () => { + const url = shareUrl() + if (!url) return + args.platform.openLink(url) + } + + return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare } +} + export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() @@ -53,62 +211,7 @@ export function SessionHeader() { const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) - - const OPEN_APPS = [ - "vscode", - "cursor", - "zed", - "textmate", - "antigravity", - "finder", - "terminal", - "iterm2", - "ghostty", - "xcode", - "android-studio", - "powershell", - "sublime-text", - ] as const - type OpenApp = (typeof OPEN_APPS)[number] - - const MAC_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, - { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, - { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, - { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const WINDOWS_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const LINUX_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, - ] as const - - const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" - }) + const os = createMemo(() => detectOS(platform)) const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true }) @@ -154,10 +257,6 @@ export function SessionHeader() { ] as const }) - type OpenIcon = OpenApp | "file-explorer" - const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"]) - const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]") - const checksReady = createMemo(() => { if (platform.platform !== "desktop") return true if (!platform.checkAppExists) return true @@ -186,13 +285,7 @@ export function SessionHeader() { const item = options().find((o) => o.id === app) const openWith = item && "openWith" in item ? item.openWith : undefined - Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err)) } const copyPath = () => { @@ -208,87 +301,16 @@ export function SessionHeader() { description: directory, }) }) - .catch((err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) + .catch((err: unknown) => showRequestError(language, err)) } - const [state, setState] = createStore({ - share: false, - unshare: false, - copied: false, - timer: undefined as number | undefined, - }) - const shareUrl = createMemo(() => currentSession()?.share?.url) - - createEffect(() => { - const url = shareUrl() - if (url) return - if (state.timer) window.clearTimeout(state.timer) - setState({ copied: false, timer: undefined }) - }) - - onCleanup(() => { - if (state.timer) window.clearTimeout(state.timer) + const share = useSessionShare({ + globalSDK, + currentSession, + projectDirectory, + platform, }) - function shareSession() { - const session = currentSession() - if (!session || state.share) return - setState("share", true) - globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .catch((error) => { - console.error("Failed to share session", error) - }) - .finally(() => { - setState("share", false) - }) - } - - function unshareSession() { - const session = currentSession() - if (!session || state.unshare) return - setState("unshare", true) - globalSDK.client.session - .unshare({ sessionID: session.id, directory: projectDirectory() }) - .catch((error) => { - console.error("Failed to unshare session", error) - }) - .finally(() => { - setState("unshare", false) - }) - } - - function copyLink() { - const url = shareUrl() - if (!url) return - navigator.clipboard - .writeText(url) - .then(() => { - if (state.timer) window.clearTimeout(state.timer) - setState("copied", true) - const timer = window.setTimeout(() => { - setState("copied", false) - setState("timer", undefined) - }, 3000) - setState("timer", timer) - }) - .catch((error) => { - console.error("Failed to copy share link", error) - }) - } - - function viewShare() { - const url = shareUrl() - if (!url) return - platform.openLink(url) - } - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -391,7 +413,7 @@ export function SessionHeader() { }} > <div class="flex size-5 shrink-0 items-center justify-center"> - <AppIcon id={o.icon} class={size(o.icon)} /> + <AppIcon id={o.icon} class={openIconSize(o.icon)} /> </div> <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> <DropdownMenu.ItemIndicator> @@ -428,7 +450,7 @@ export function SessionHeader() { <Popover title={language.t("session.share.popover.title")} description={ - shareUrl() + share.shareUrl() ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared") } @@ -441,24 +463,24 @@ export function SessionHeader() { variant: "ghost", class: "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", - classList: { "rounded-r-none": shareUrl() !== undefined }, + classList: { "rounded-r-none": share.shareUrl() !== undefined }, style: { scale: 1 }, }} trigger={language.t("session.share.action.share")} > <div class="flex flex-col gap-2"> <Show - when={shareUrl()} + when={share.shareUrl()} fallback={ <div class="flex"> <Button size="large" variant="primary" class="w-1/2" - onClick={shareSession} - disabled={state.share} + onClick={share.shareSession} + disabled={share.state.share} > - {state.share + {share.state.share ? language.t("session.share.action.publishing") : language.t("session.share.action.publish")} </Button> @@ -467,7 +489,7 @@ export function SessionHeader() { > <div class="flex flex-col gap-2"> <TextField - value={shareUrl() ?? ""} + value={share.shareUrl() ?? ""} readOnly copyable copyKind="link" @@ -479,10 +501,10 @@ export function SessionHeader() { size="large" variant="secondary" class="w-full shadow-none border border-border-weak-base" - onClick={unshareSession} - disabled={state.unshare} + onClick={share.unshareSession} + disabled={share.state.unshare} > - {state.unshare + {share.state.unshare ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")} </Button> @@ -490,8 +512,8 @@ export function SessionHeader() { size="large" variant="primary" class="w-full" - onClick={viewShare} - disabled={state.unshare} + onClick={share.viewShare} + disabled={share.state.unshare} > {language.t("session.share.action.view")} </Button> @@ -500,10 +522,10 @@ export function SessionHeader() { </Show> </div> </Popover> - <Show when={shareUrl()} fallback={<div aria-hidden="true" />}> + <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}> <Tooltip value={ - state.copied + share.state.copied ? language.t("session.share.copy.copied") : language.t("session.share.copy.copyLink") } @@ -511,13 +533,13 @@ export function SessionHeader() { gutter={8} > <IconButton - icon={state.copied ? "check" : "link"} + icon={share.state.copied ? "check" : "link"} variant="ghost" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none" - onClick={copyLink} - disabled={state.unshare} + onClick={() => share.copyLink((error) => showRequestError(language, error))} + disabled={share.state.unshare} aria-label={ - state.copied + share.state.copied ? language.t("session.share.copy.copied") : language.t("session.share.copy.copyLink") } diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 480cd58c1..ab96652d4 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" +const ROOT_CLASS = + "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]" interface NewSessionViewProps { worktree: string @@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) { } return ( - <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"> + <div class={ROOT_CLASS}> <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div> <div class="flex justify-center items-center gap-3"> <Icon name="folder" size="small" /> diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 516f3c8ed..b94e7a8e9 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return <FileVisual path={value} /> + }) return ( - // @ts-ignore <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> <div class="relative h-full"> <Tabs.Trigger @@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} > - <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show> + <Show when={content()}>{(value) => value()}</Show> </Tabs.Trigger> </div> </div> diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index aedf67876..6fe6186d5 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js" -import { Show } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => menuPosition: { x: 0, y: 0 }, blurEnabled: false, }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined const isDefaultTitle = () => { const number = props.terminal.titleNumber @@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => setStore("blurEnabled", false) setStore("title", props.terminal.title) setStore("editing", true) - setTimeout(() => { - const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement - if (!input) return - input.focus() - input.select() - setTimeout(() => setStore("blurEnabled", true), 100) - }, 10) } const save = () => { @@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => setStore("menuOpen", true) } + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + return ( <div - // @ts-ignore use:sortable class="outline-none focus:outline-none focus-visible:outline-none" classList={{ @@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => <Show when={store.editing}> <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto"> <input - id={`terminal-title-input-${props.terminal.id}`} + ref={input} type="text" value={store.title} onInput={(e) => setStore("title", e.currentTarget.value)} diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx index e68f1e59c..74a942f77 100644 --- a/packages/app/src/components/settings-agents.tsx +++ b/packages/app/src/components/settings-agents.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsAgents: Component = () => { + // TODO: Replace this placeholder with full agents settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx index cf796d0aa..e158d231c 100644 --- a/packages/app/src/components/settings-commands.tsx +++ b/packages/app/src/components/settings-commands.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsCommands: Component = () => { + // TODO: Replace this placeholder with full commands settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 72135c342..c673cab80 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,4 +1,4 @@ -import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] + const soundSelectProps = (current: () => string, set: (id: string) => void) => ({ + options: soundOptions, + current: soundOptions.find((o) => o.id === current()), + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.src) + }, + onSelect: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + set(option.id) + playDemoSound(option.src) + }, + variant: "secondary" as const, + size: "small" as const, + triggerVariant: "settings" as const, + }) + + const AppearanceSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.row.language.title")} + description={language.t("settings.general.row.language.description")} + > + <Select + data-action="settings-language" + options={languageOptions()} + current={languageOptions().find((o) => o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.appearance.title")} + description={language.t("settings.general.row.appearance.description")} + > + <Select + data-action="settings-color-scheme" + options={colorSchemeOptions()} + current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.theme.title")} + description={ + <> + {language.t("settings.general.row.theme.description")}{" "} + <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link> + </> + } + > + <Select + data-action="settings-theme" + options={themeOptions()} + current={themeOptions().find((o) => o.id === theme.themeId())} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.font.title")} + description={language.t("settings.general.row.font.description")} + > + <Select + data-action="settings-font" + options={fontOptionsList} + current={fontOptionsList.find((o) => o.value === settings.appearance.font())} + value={(o) => o.value} + label={(o) => language.t(o.label)} + onSelect={(option) => option && settings.appearance.setFont(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + > + {(option) => ( + <span style={{ "font-family": monoFontFamily(option?.value) }}> + {option ? language.t(option.label) : ""} + </span> + )} + </Select> + </SettingsRow> + </div> + </div> + ) + + const NotificationsSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.notifications.agent.title")} + description={language.t("settings.general.notifications.agent.description")} + > + <div data-action="settings-notifications-agent"> + <Switch + checked={settings.notifications.agent()} + onChange={(checked) => settings.notifications.setAgent(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.notifications.permissions.title")} + description={language.t("settings.general.notifications.permissions.description")} + > + <div data-action="settings-notifications-permissions"> + <Switch + checked={settings.notifications.permissions()} + onChange={(checked) => settings.notifications.setPermissions(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.notifications.errors.title")} + description={language.t("settings.general.notifications.errors.description")} + > + <div data-action="settings-notifications-errors"> + <Switch + checked={settings.notifications.errors()} + onChange={(checked) => settings.notifications.setErrors(checked)} + /> + </div> + </SettingsRow> + </div> + </div> + ) + + const SoundsSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.general.sounds.agent.title")} + description={language.t("settings.general.sounds.agent.description")} + > + <Select + data-action="settings-sounds-agent" + {...soundSelectProps( + () => settings.sounds.agent(), + (id) => settings.sounds.setAgent(id), + )} + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.sounds.permissions.title")} + description={language.t("settings.general.sounds.permissions.description")} + > + <Select + data-action="settings-sounds-permissions" + {...soundSelectProps( + () => settings.sounds.permissions(), + (id) => settings.sounds.setPermissions(id), + )} + /> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.sounds.errors.title")} + description={language.t("settings.general.sounds.errors.description")} + > + <Select + data-action="settings-sounds-errors" + {...soundSelectProps( + () => settings.sounds.errors(), + (id) => settings.sounds.setErrors(id), + )} + /> + </SettingsRow> + </div> + </div> + ) + + const UpdatesSection = () => ( + <div class="flex flex-col gap-1"> + <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> + + <div class="bg-surface-raised-base px-4 rounded-lg"> + <SettingsRow + title={language.t("settings.updates.row.startup.title")} + description={language.t("settings.updates.row.startup.description")} + > + <div data-action="settings-updates-startup"> + <Switch + checked={settings.updates.startup()} + disabled={!platform.checkUpdate} + onChange={(checked) => settings.updates.setStartup(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.general.row.releaseNotes.title")} + description={language.t("settings.general.row.releaseNotes.description")} + > + <div data-action="settings-release-notes"> + <Switch + checked={settings.general.releaseNotes()} + onChange={(checked) => settings.general.setReleaseNotes(checked)} + /> + </div> + </SettingsRow> + + <SettingsRow + title={language.t("settings.updates.row.check.title")} + description={language.t("settings.updates.row.check.description")} + > + <Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}> + {store.checking + ? language.t("settings.updates.action.checking") + : language.t("settings.updates.action.checkNow")} + </Button> + </SettingsRow> + </div> + </div> + ) + return ( <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> @@ -142,230 +397,11 @@ export const SettingsGeneral: Component = () => { </div> <div class="flex flex-col gap-8 w-full"> - {/* Appearance Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.row.language.title")} - description={language.t("settings.general.row.language.description")} - > - <Select - data-action="settings-language" - options={languageOptions()} - current={languageOptions().find((o) => o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.appearance.title")} - description={language.t("settings.general.row.appearance.description")} - > - <Select - data-action="settings-color-scheme" - options={colorSchemeOptions()} - current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.theme.title")} - description={ - <> - {language.t("settings.general.row.theme.description")}{" "} - <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link> - </> - } - > - <Select - data-action="settings-theme" - options={themeOptions()} - current={themeOptions().find((o) => o.id === theme.themeId())} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.font.title")} - description={language.t("settings.general.row.font.description")} - > - <Select - data-action="settings-font" - options={fontOptionsList} - current={fontOptionsList.find((o) => o.value === settings.appearance.font())} - value={(o) => o.value} - label={(o) => language.t(o.label)} - onSelect={(option) => option && settings.appearance.setFont(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} - > - {(option) => ( - <span style={{ "font-family": monoFontFamily(option?.value) }}> - {option ? language.t(option.label) : ""} - </span> - )} - </Select> - </SettingsRow> - </div> - </div> + <AppearanceSection /> - {/* System notifications Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.notifications.agent.title")} - description={language.t("settings.general.notifications.agent.description")} - > - <div data-action="settings-notifications-agent"> - <Switch - checked={settings.notifications.agent()} - onChange={(checked) => settings.notifications.setAgent(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.notifications.permissions.title")} - description={language.t("settings.general.notifications.permissions.description")} - > - <div data-action="settings-notifications-permissions"> - <Switch - checked={settings.notifications.permissions()} - onChange={(checked) => settings.notifications.setPermissions(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.notifications.errors.title")} - description={language.t("settings.general.notifications.errors.description")} - > - <div data-action="settings-notifications-errors"> - <Switch - checked={settings.notifications.errors()} - onChange={(checked) => settings.notifications.setErrors(checked)} - /> - </div> - </SettingsRow> - </div> - </div> + <NotificationsSection /> - {/* Sound effects Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.general.sounds.agent.title")} - description={language.t("settings.general.sounds.agent.description")} - > - <Select - data-action="settings-sounds-agent" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.agent())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setAgent(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.sounds.permissions.title")} - description={language.t("settings.general.sounds.permissions.description")} - > - <Select - data-action="settings-sounds-permissions" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.permissions())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setPermissions(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.sounds.errors.title")} - description={language.t("settings.general.sounds.errors.description")} - > - <Select - data-action="settings-sounds-errors" - options={soundOptions} - current={soundOptions.find((o) => o.id === settings.sounds.errors())} - value={(o) => o.id} - label={(o) => language.t(o.label)} - onHighlight={(option) => { - if (!option) return - playDemoSound(option.src) - }} - onSelect={(option) => { - if (!option) return - settings.sounds.setErrors(option.id) - playDemoSound(option.src) - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - </div> - </div> + <SoundsSection /> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> {(_) => { @@ -395,53 +431,7 @@ export const SettingsGeneral: Component = () => { }} </Show> - {/* Updates Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> - - <div class="bg-surface-raised-base px-4 rounded-lg"> - <SettingsRow - title={language.t("settings.updates.row.startup.title")} - description={language.t("settings.updates.row.startup.description")} - > - <div data-action="settings-updates-startup"> - <Switch - checked={settings.updates.startup()} - disabled={!platform.checkUpdate} - onChange={(checked) => settings.updates.setStartup(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.general.row.releaseNotes.title")} - description={language.t("settings.general.row.releaseNotes.description")} - > - <div data-action="settings-release-notes"> - <Switch - checked={settings.general.releaseNotes()} - onChange={(checked) => settings.general.setReleaseNotes(checked)} - /> - </div> - </SettingsRow> - - <SettingsRow - title={language.t("settings.updates.row.check.title")} - description={language.t("settings.updates.row.check.description")} - > - <Button - size="small" - variant="secondary" - disabled={store.checking || !platform.checkUpdate} - onClick={check} - > - {store.checking - ? language.t("settings.updates.action.checking") - : language.t("settings.updates.action.checkNow")} - </Button> - </SettingsRow> - </div> - </div> + <UpdatesSection /> <Show when={linux()}> {(_) => { diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 79e000f37..bcc731af9 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -21,6 +21,9 @@ type KeybindMeta = { group: KeybindGroup } +type KeybindMap = Record<string, string | undefined> +type CommandContext = ReturnType<typeof useCommand> + const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] type GroupKey = @@ -107,6 +110,150 @@ function signatures(config: string | undefined) { return sigs } +function keybinds(value: unknown): KeybindMap { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as KeybindMap +} + +function listFor(command: CommandContext, map: KeybindMap, palette: string) { + const out = new Map<string, KeybindMeta>() + out.set(PALETTE_ID, { title: palette, group: "General" }) + + for (const opt of command.catalog) { + if (opt.id.startsWith("suggested.")) continue + out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) + } + + for (const opt of command.options) { + if (opt.id.startsWith("suggested.")) continue + out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) + } + + for (const [id, value] of Object.entries(map)) { + if (typeof value !== "string") continue + if (out.has(id)) continue + out.set(id, { title: id, group: groupFor(id) }) + } + + return out +} + +function groupedFor(list: Map<string, KeybindMeta>) { + const out = new Map<KeybindGroup, string[]>() + for (const group of GROUPS) out.set(group, []) + + for (const [id, item] of list) { + const ids = out.get(item.group) + if (!ids) continue + ids.push(id) + } + + for (const group of GROUPS) { + const ids = out.get(group) + if (!ids) continue + ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? "")) + } + + return out +} + +function filteredFor( + query: string, + list: Map<string, KeybindMeta>, + grouped: Map<KeybindGroup, string[]>, + keybind: (id: string) => string, +) { + const value = query.toLowerCase().trim() + if (!value) return grouped + + const out = new Map<KeybindGroup, string[]>() + for (const group of GROUPS) out.set(group, []) + + const items = Array.from(list.entries()).map(([id, meta]) => ({ + id, + title: meta.title, + group: meta.group, + keybind: keybind(id), + })) + + const results = fuzzysort.go(value, items, { + keys: ["title", "keybind"], + threshold: -10000, + }) + + for (const result of results) { + const ids = out.get(result.obj.group) + if (!ids) continue + ids.push(result.obj.id) + } + + return out +} + +function useKeyCapture(input: { + active: () => string | null + stop: () => void + set: (id: string, keybind: string) => void + used: () => Map<string, { id: string; title: string }[]> + language: ReturnType<typeof useLanguage> +}) { + onMount(() => { + const handle = (event: KeyboardEvent) => { + const id = input.active() + if (!id) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + if (event.key === "Escape") { + input.stop() + return + } + + const clear = + (event.key === "Backspace" || event.key === "Delete") && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + if (clear) { + input.set(id, "none") + input.stop() + return + } + + const next = recordKeybind(event) + if (!next) return + + const conflicts = new Map<string, string>() + for (const sig of signatures(next)) { + for (const item of input.used().get(sig) ?? []) { + if (item.id === id) continue + conflicts.set(item.id, item.title) + } + } + + if (conflicts.size > 0) { + showToast({ + title: input.language.t("settings.shortcuts.conflict.title"), + description: input.language.t("settings.shortcuts.conflict.description", { + keybind: formatKeybind(next), + titles: [...conflicts.values()].join(", "), + }), + }) + return + } + + input.set(id, next) + input.stop() + } + + document.addEventListener("keydown", handle, true) + onCleanup(() => document.removeEventListener("keydown", handle, true)) + }) +} + export const SettingsKeybinds: Component = () => { const command = useCommand() const language = useLanguage() @@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => { command.keybinds(false) } - const hasOverrides = createMemo(() => { - const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined - if (!keybinds) return false - return Object.values(keybinds).some((x) => typeof x === "string") - }) + const map = createMemo(() => keybinds(settings.current.keybinds)) + + const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string")) const resetAll = () => { stop() @@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => { const list = createMemo(() => { language.locale() - const out = new Map<string, KeybindMeta>() - out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" }) - - for (const opt of command.catalog) { - if (opt.id.startsWith("suggested.")) continue - out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) - } - - for (const opt of command.options) { - if (opt.id.startsWith("suggested.")) continue - out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) - } - - const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined - if (keybinds) { - for (const [id, value] of Object.entries(keybinds)) { - if (typeof value !== "string") continue - if (out.has(id)) continue - out.set(id, { title: id, group: groupFor(id) }) - } - } - - return out + return listFor(command, map(), language.t("command.palette")) }) const title = (id: string) => list().get(id)?.title ?? "" - const grouped = createMemo(() => { - const map = list() - const out = new Map<KeybindGroup, string[]>() - - for (const group of GROUPS) out.set(group, []) - - for (const [id, item] of map) { - const ids = out.get(item.group) - if (!ids) continue - ids.push(id) - } - - for (const group of GROUPS) { - const ids = out.get(group) - if (!ids) continue - - ids.sort((a, b) => { - const at = map.get(a)?.title ?? "" - const bt = map.get(b)?.title ?? "" - return at.localeCompare(bt) - }) - } - - return out - }) + const grouped = createMemo(() => groupedFor(list())) const filtered = createMemo(() => { - const query = store.filter.toLowerCase().trim() - if (!query) return grouped() - - const map = list() - const out = new Map<KeybindGroup, string[]>() - - for (const group of GROUPS) out.set(group, []) - - const items = Array.from(map.entries()).map(([id, meta]) => ({ - id, - title: meta.title, - group: meta.group, - keybind: command.keybind(id) || "", - })) - - const results = fuzzysort.go(query, items, { - keys: ["title", "keybind"], - threshold: -10000, - }) - - for (const result of results) { - const item = result.obj - const ids = out.get(item.group) - if (!ids) continue - ids.push(item.id) - } - - return out + return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "") }) const hasResults = createMemo(() => { @@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => { return map }) - const setKeybind = (id: string, keybind: string) => { - settings.keybinds.set(id, keybind) - } - - onMount(() => { - const handle = (event: KeyboardEvent) => { - const id = store.active - if (!id) return - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() + const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind) - if (event.key === "Escape") { - stop() - return - } - - const clear = - (event.key === "Backspace" || event.key === "Delete") && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - if (clear) { - setKeybind(id, "none") - stop() - return - } - - const next = recordKeybind(event) - if (!next) return - - const map = used() - const conflicts = new Map<string, string>() - - for (const sig of signatures(next)) { - const list = map.get(sig) ?? [] - for (const item of list) { - if (item.id === id) continue - conflicts.set(item.id, item.title) - } - } - - if (conflicts.size > 0) { - showToast({ - title: language.t("settings.shortcuts.conflict.title"), - description: language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next), - titles: [...conflicts.values()].join(", "), - }), - }) - return - } - - setKeybind(id, next) - stop() - } - - document.addEventListener("keydown", handle, true) - onCleanup(() => { - document.removeEventListener("keydown", handle, true) - }) + useKeyCapture({ + active: () => store.active, + stop, + set: setKeybind, + used, + language, }) onCleanup(() => { diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx index 928464a51..507e041aa 100644 --- a/packages/app/src/components/settings-mcp.tsx +++ b/packages/app/src/components/settings-mcp.tsx @@ -2,6 +2,7 @@ import { Component } from "solid-js" import { useLanguage } from "@/context/language" export const SettingsMcp: Component = () => { + // TODO: Replace this placeholder with full MCP settings controls. const language = useLanguage() return ( diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 1807d561e..3a0b7a4fb 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers" type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] +const ListLoadingState: Component<{ label: string }> = (props) => { + return ( + <div class="flex flex-col items-center justify-center py-12 text-center"> + <span class="text-14-regular text-text-weak">{props.label}</span> + </div> + ) +} + +const ListEmptyState: Component<{ message: string; filter: string }> = (props) => { + return ( + <div class="flex flex-col items-center justify-center py-12 text-center"> + <span class="text-14-regular text-text-weak">{props.message}</span> + <Show when={props.filter}> + <span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span> + </Show> + </div> + ) +} + export const SettingsModels: Component = () => { const language = useLanguage() const models = useModels() @@ -68,24 +87,12 @@ export const SettingsModels: Component = () => { <Show when={!list.grouped.loading} fallback={ - <div class="flex flex-col items-center justify-center py-12 text-center"> - <span class="text-14-regular text-text-weak"> - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} - </span> - </div> + <ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} /> } > <Show when={list.flat().length > 0} - fallback={ - <div class="flex flex-col items-center justify-center py-12 text-center"> - <span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span> - <Show when={list.filter()}> - <span class="text-14-regular text-text-strong mt-1">"{list.filter()}"</span> - </Show> - </div> - } + fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />} > <For each={list.grouped.latest}> {(group) => ( diff --git a/packages/app/src/components/settings-permissions.tsx b/packages/app/src/components/settings-permissions.tsx index 7dd43a707..348854491 100644 --- a/packages/app/src/components/settings-permissions.tsx +++ b/packages/app/src/components/settings-permissions.tsx @@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => { const nextValue = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action - globalSync.set("config", "permission", { ...map, [id]: nextValue }) - globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => { + const rollback = (err: unknown) => { globalSync.set("config", "permission", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message }) - }) + } + + globalSync.set("config", "permission", { ...map, [id]: nextValue }) + globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback) } return ( diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index d2444e2d2..a3375c9c6 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" type ProviderSource = "env" | "api" | "config" | "custom" -type ProviderMeta = { source?: ProviderSource } +type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] + +const PROVIDER_NOTES = [ + { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, + { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, + { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, + { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, +] as const export const SettingsProviders: Component = () => { const dialog = useDialog() @@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => { return items }) - const source = (item: unknown) => (item as ProviderMeta).source + const source = (item: ProviderItem): ProviderSource | undefined => { + if (!("source" in item)) return + const value = item.source + if (value === "env" || value === "api" || value === "config" || value === "custom") return value + return + } - const type = (item: unknown) => { + const type = (item: ProviderItem) => { const current = source(item) if (current === "env") return language.t("settings.providers.tag.environment") if (current === "api") return language.t("provider.connect.method.apiKey") if (current === "config") { - const id = (item as { id?: string }).id - if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom") + if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.config") } if (current === "custom") return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.other") } - const canDisconnect = (item: unknown) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => source(item) !== "env" + + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const isConfigCustom = (providerID: string) => { const provider = globalSync.data.config.provider?.[providerID] @@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => { <Tag>{language.t("dialog.provider.tag.recommended")}</Tag> </Show> </div> - <Show when={item.id === "opencode"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.opencode.note")} - </span> - </Show> - <Show when={item.id === "anthropic"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.anthropic.note")} - </span> - </Show> - <Show when={item.id.startsWith("github-copilot")}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.copilot.note")} - </span> - </Show> - <Show when={item.id === "openai"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.openai.note")} - </span> - </Show> - <Show when={item.id === "google"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.google.note")} - </span> - </Show> - <Show when={item.id === "openrouter"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.openrouter.note")} - </span> - </Show> - <Show when={item.id === "vercel"}> - <span class="text-12-regular text-text-weak pl-8"> - {language.t("dialog.provider.vercel.note")} - </span> + <Show when={note(item.id)}> + {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>} </Show> </div> <Button diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 6e8999017..26ee2d070 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -7,134 +7,189 @@ 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 { showToast } from "@opencode-ai/ui/toast" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { DialogSelectServer } from "./dialog-select-server" -import { showToast } from "@opencode-ai/ui/toast" import { ServerRow } from "@/components/server/server-row" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" -export function StatusPopover() { - const sync = useSync() - const sdk = useSDK() - const server = useServer() - const platform = usePlatform() - const dialog = useDialog() - const language = useLanguage() - const navigate = useNavigate() +const pollMs = 10_000 - const [store, setStore] = createStore({ - status: {} as Record<string, ServerHealth | undefined>, - loading: null as string | null, - defaultServerUrl: undefined as string | undefined, - }) - const fetcher = platform.fetch ?? globalThis.fetch +const pluginEmptyMessage = (value: string, file: string): JSXElement => { + const parts = value.split(file) + if (parts.length === 1) return value + return ( + <> + {parts[0]} + <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code> + {parts.slice(1).join(file)} + </> + ) +} - const servers = createMemo(() => { - const current = server.url - const list = server.list - if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((x) => x !== current)] +const listServersByHealth = ( + list: string[], + active: string | undefined, + status: Record<string, ServerHealth | undefined>, +) => { + if (!list.length) return list + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(status[a]) - rank(status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) +} - const sortedServers = createMemo(() => { +const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => { + const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) + + createEffect(() => { const list = servers() - if (!list.length) return list - const active = server.url - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 + let dead = false + + const refresh = async () => { + const results: Record<string, ServerHealth> = {} + await Promise.all( + list.map(async (url) => { + results[url] = await checkServerHealth(url, fetcher) + }), + ) + if (dead) return + setStatus(reconcile(results)) } - return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = rank(store.status[a]) - rank(store.status[b]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) + + void refresh() + const id = setInterval(() => void refresh(), pollMs) + onCleanup(() => { + dead = true + clearInterval(id) }) }) - async function refreshHealth() { - const results: Record<string, ServerHealth> = {} - await Promise.all( - servers().map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) - }), - ) - setStore("status", reconcile(results)) - } + return status +} + +const useDefaultServerUrl = ( + get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, +) => { + const [url, setUrl] = createSignal<string | undefined>() + const [tick, setTick] = createSignal(0) createEffect(() => { - servers() - refreshHealth() - const interval = setInterval(refreshHealth, 10_000) - onCleanup(() => clearInterval(interval)) + tick() + let dead = false + const result = get?.() + if (!result) { + setUrl(undefined) + onCleanup(() => { + dead = true + }) + return + } + + if (result instanceof Promise) { + void result.then((next) => { + if (dead) return + setUrl(next ? normalizeServerUrl(next) : undefined) + }) + onCleanup(() => { + dead = true + }) + return + } + + setUrl(normalizeServerUrl(result)) + onCleanup(() => { + dead = true + }) }) - const mcpItems = createMemo(() => - Object.entries(sync.data.mcp ?? {}) - .map(([name, status]) => ({ name, status: status.status })) - .sort((a, b) => a.name.localeCompare(b.name)), - ) + return { url, refresh: () => setTick((value) => value + 1) } +} - const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) +const useMcpToggle = (input: { + sync: ReturnType<typeof useSync> + sdk: ReturnType<typeof useSDK> + language: ReturnType<typeof useLanguage> +}) => { + const [loading, setLoading] = createSignal<string | null>(null) - const toggleMcp = async (name: string) => { - if (store.loading) return - setStore("loading", name) + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) try { - const status = sync.data.mcp[name] - await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) + const status = input.sync.data.mcp[name] + await (status?.status === "connected" + ? input.sdk.client.mcp.disconnect({ name }) + : input.sdk.client.mcp.connect({ name })) + const result = await input.sdk.client.mcp.status() + if (result.data) input.sync.set("mcp", result.data) } catch (err) { showToast({ variant: "error", - title: language.t("common.requestFailed"), + title: input.language.t("common.requestFailed"), description: err instanceof Error ? err.message : String(err), }) } finally { - setStore("loading", null) + setLoading(null) } } + return { loading, toggle } +} + +export function StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const fetcher = platform.fetch ?? globalThis.fetch + const servers = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((item) => item !== current)] + }) + const health = useServerHealth(servers, fetcher) + const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) + const mcp = useMcpToggle({ sync, sdk, language }) + const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length) const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) const plugins = createMemo(() => sync.data.config.plugin ?? []) const pluginCount = createMemo(() => plugins().length) - + const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const overallHealthy = createMemo(() => { const serverHealthy = server.healthy() === true - const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled") + const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled") return serverHealthy && !anyMcpIssue }) - const serverCount = createMemo(() => sortedServers().length) - - const refreshDefaultServerUrl = () => { - const result = platform.getDefaultServerUrl?.() - if (!result) { - setStore("defaultServerUrl", undefined) - return - } - if (result instanceof Promise) { - result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined)) - return - } - setStore("defaultServerUrl", normalizeServerUrl(result)) - } - - createEffect(() => { - refreshDefaultServerUrl() - }) - return ( <Popover triggerAs={Button} @@ -173,7 +228,7 @@ export function StatusPopover() { > <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> - {serverCount() > 0 ? `${serverCount()} ` : ""} + {sortedServers().length > 0 ? `${sortedServers().length} ` : ""} {language.t("status.popover.tab.servers")} </Tabs.Trigger> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> @@ -195,11 +250,7 @@ export function StatusPopover() { <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <For each={sortedServers()}> {(url) => { - const isActive = () => url === server.url - const isDefault = () => url === store.defaultServerUrl - const status = () => store.status[url] - const isBlocked = () => status()?.healthy === false - + const isBlocked = () => health[url]?.healthy === false return ( <button type="button" @@ -217,13 +268,13 @@ export function StatusPopover() { > <ServerRow url={url} - status={status()} + status={health[url]} 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()}> + <Show when={url === defaultServer.url()}> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> {language.t("common.default")} </span> @@ -231,7 +282,7 @@ export function StatusPopover() { } > <div class="flex-1" /> - <Show when={isActive()}> + <Show when={url === server.url}> <Icon name="check" size="small" class="text-icon-weak shrink-0" /> </Show> </ServerRow> @@ -243,7 +294,7 @@ export function StatusPopover() { <Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" - onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)} + onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)} > {language.t("status.popover.action.manageServers")} </Button> @@ -269,8 +320,8 @@ export function StatusPopover() { <button type="button" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" - onClick={() => toggleMcp(item.name)} - disabled={store.loading === item.name} + onClick={() => mcp.toggle(item.name)} + disabled={mcp.loading() === item.name} > <div classList={{ @@ -286,8 +337,8 @@ export function StatusPopover() { <div onClick={(event) => event.stopPropagation()}> <Switch checked={enabled()} - disabled={store.loading === item.name} - onChange={() => toggleMcp(item.name)} + disabled={mcp.loading() === item.name} + onChange={() => mcp.toggle(item.name)} /> </div> </button> @@ -334,23 +385,7 @@ export function StatusPopover() { <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <Show when={plugins().length > 0} - fallback={ - <div class="text-14-regular text-text-base text-center my-auto"> - {(() => { - const value = language.t("dialog.plugins.empty") - const file = "opencode.json" - const parts = value.split(file) - if (parts.length === 1) return value - return ( - <> - {parts[0]} - <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code> - {parts.slice(1).join(file)} - </> - ) - })()} - </div> - } + fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>} > <For each={plugins()}> {(plugin) => ( diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 09c04db40..f6bb0b48a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -56,6 +56,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { }, } +const debugTerminal = (...values: unknown[]) => { + if (!import.meta.env.DEV) return + console.debug("[terminal]", ...values) +} + +const useTerminalUiBindings = (input: { + container: HTMLDivElement + term: Term + cleanups: VoidFunction[] + handlePointerDown: () => void + handleLinkClick: (event: MouseEvent) => void +}) => { + const handleCopy = (event: ClipboardEvent) => { + const selection = input.term.getSelection() + if (!selection) return + + const clipboard = event.clipboardData + if (!clipboard) return + + event.preventDefault() + clipboard.setData("text/plain", selection) + } + + const handlePaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData + const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" + if (!text) return + + event.preventDefault() + event.stopPropagation() + input.term.paste(text) + } + + const handleTextareaFocus = () => { + input.term.options.cursorBlink = true + } + const handleTextareaBlur = () => { + input.term.options.cursorBlink = false + } + + input.container.addEventListener("copy", handleCopy, true) + input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) + + input.container.addEventListener("paste", handlePaste, true) + input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) + + input.container.addEventListener("pointerdown", input.handlePointerDown) + input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) + + input.container.addEventListener("click", input.handleLinkClick, { capture: true }) + input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) + + input.term.textarea?.addEventListener("focus", handleTextareaFocus) + input.term.textarea?.addEventListener("blur", handleTextareaBlur) + input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) + input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) +} + +const persistTerminal = (input: { + term: Term | undefined + addon: SerializeAddon | undefined + cursor: number + pty: LocalPTY + onCleanup?: (pty: LocalPTY) => void +}) => { + if (!input.addon || !input.onCleanup || !input.term) return + const buffer = (() => { + try { + return input.addon.serialize() + } catch { + debugTerminal("failed to serialize terminal buffer") + return "" + } + })() + + input.onCleanup({ + ...input.pty, + buffer, + cursor: input.cursor, + rows: input.term.rows, + cols: input.term.cols, + scrollY: input.term.getViewportY(), + }) +} + export const Terminal = (props: TerminalProps) => { const platform = usePlatform() const sdk = useSDK() @@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void - let handleTextareaFocus: () => void - let handleTextareaBlur: () => void let disposed = false const cleanups: VoidFunction[] = [] const start = @@ -84,12 +167,23 @@ export const Terminal = (props: TerminalProps) => { for (const fn of fns) { try { fn() - } catch { - // ignore + } catch (err) { + debugTerminal("cleanup failed", err) } } } + const pushSize = (cols: number, rows: number) => { + return sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + const getTerminalColors = (): TerminalColors => { const mode = theme.mode() === "dark" ? "dark" : "light" const fallback = DEFAULT_TERMINAL_COLORS[mode] @@ -219,27 +313,6 @@ export const Terminal = (props: TerminalProps) => { ghostty = g term = t - const handleCopy = (event: ClipboardEvent) => { - const selection = t.getSelection() - if (!selection) return - - const clipboard = event.clipboardData - if (!clipboard) return - - event.preventDefault() - clipboard.setData("text/plain", selection) - } - - const handlePaste = (event: ClipboardEvent) => { - const clipboard = event.clipboardData - const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" - if (!text) return - - event.preventDefault() - event.stopPropagation() - t.paste(text) - } - t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() @@ -255,12 +328,6 @@ export const Terminal = (props: TerminalProps) => { return matchKeybind(keybinds, event) }) - container.addEventListener("copy", handleCopy, true) - cleanups.push(() => container.removeEventListener("copy", handleCopy, true)) - - container.addEventListener("paste", handlePaste, true) - cleanups.push(() => container.removeEventListener("paste", handlePaste, true)) - const fit = new mod.FitAddon() const serializer = new SerializeAddon() cleanups.push(() => disposeIfDisposable(fit)) @@ -270,24 +337,7 @@ export const Terminal = (props: TerminalProps) => { serializeAddon = serializer t.open(container) - - container.addEventListener("pointerdown", handlePointerDown) - cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown)) - - container.addEventListener("click", handleLinkClick, { capture: true }) - cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true })) - - handleTextareaFocus = () => { - t.options.cursorBlink = true - } - handleTextareaBlur = () => { - t.options.cursorBlink = false - } - - t.textarea?.addEventListener("focus", handleTextareaFocus) - t.textarea?.addEventListener("blur", handleTextareaBlur) - cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus)) - cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur)) + useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) focusTerminal() @@ -316,15 +366,7 @@ export const Terminal = (props: TerminalProps) => { const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { - await sdk.client.pty - .update({ - ptyID: local.pty.id, - size: { - cols: size.cols, - rows: size.rows, - }, - }) - .catch(() => {}) + await pushSize(size.cols, size.rows) } }) cleanups.push(() => disposeIfDisposable(onResize)) @@ -346,15 +388,7 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { local.onConnect?.() - sdk.client.pty - .update({ - ptyID: local.pty.id, - size: { - cols: t.cols, - rows: t.rows, - }, - }) - .catch(() => {}) + void pushSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) @@ -374,8 +408,8 @@ export const Terminal = (props: TerminalProps) => { if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { cursor = next } - } catch { - // ignore + } catch (err) { + debugTerminal("invalid websocket control frame", err) } return } @@ -425,25 +459,7 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true - const t = term - if (serializeAddon && props.onCleanup && t) { - const buffer = (() => { - try { - return serializeAddon.serialize() - } catch { - return "" - } - })() - props.onCleanup({ - ...local.pty, - buffer, - cursor, - rows: t.rows, - cols: t.cols, - scrollY: t.getViewportY(), - }) - } - + persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e7b8066ae..039a25fae 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -13,6 +13,28 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { applyPath, backPath, forwardPath } from "./titlebar-history" +type TauriDesktopWindow = { + startDragging?: () => Promise<void> + toggleMaximize?: () => Promise<void> +} + +type TauriThemeWindow = { + setTheme?: (theme?: "light" | "dark" | null) => Promise<void> +} + +type TauriApi = { + window?: { + getCurrentWindow?: () => TauriDesktopWindow + } + webviewWindow?: { + getCurrentWebviewWindow?: () => TauriThemeWindow + } +} + +const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ +const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() +const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() + export function Titlebar() { const layout = useLayout() const platform = usePlatform() @@ -82,22 +104,7 @@ export function Titlebar() { const getWin = () => { if (platform.platform !== "desktop") return - - const tauri = ( - window as unknown as { - __TAURI__?: { - window?: { - getCurrentWindow?: () => { - startDragging?: () => Promise<void> - toggleMaximize?: () => Promise<void> - } - } - } - } - ).__TAURI__ - if (!tauri?.window?.getCurrentWindow) return - - return tauri.window.getCurrentWindow() + return currentDesktopWindow() } createEffect(() => { @@ -106,13 +113,8 @@ export function Titlebar() { const scheme = theme.colorScheme() const value = scheme === "system" ? null : scheme - const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) - .__TAURI__ - const get = tauri?.webviewWindow?.getCurrentWebviewWindow - if (!get) return - - const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> } - if (!win.setTheme) return + const win = currentThemeWindow() + if (!win?.setTheme) return void win.setTheme(value).catch(() => undefined) }) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e6a16fd4b..b286364c6 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na const PALETTE_ID = "command.palette" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." +const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"]) function actionId(id: string) { if (!id.startsWith(SUGGESTED_PREFIX)) return id @@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) { return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) } +function isAllowedEditableKeybind(id: string | undefined) { + if (!id) return false + return EDITABLE_KEYBIND_IDS.has(actionId(id)) +} + export type KeybindConfig = string export interface Keybind { @@ -56,6 +62,8 @@ export interface CommandOption { onHighlight?: () => (() => void) | void } +type CommandSource = "palette" | "keybind" | "slash" + export type CommandCatalogItem = { title: string description?: string @@ -169,6 +177,14 @@ 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: () => { @@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return map }) - const run = (id: string, source?: "palette" | "keybind" | "slash") => { + const optionMap = createMemo(() => { + const map = new Map<string, CommandOption>() for (const option of options()) { - if (option.id === id || option.id === "suggested." + id) { - option.onSelect?.(source) - return - } + map.set(option.id, option) + map.set(actionId(option.id), option) } + return map + }) + + const run = (id: string, source?: CommandSource) => { + const option = optionMap().get(id) + option?.onSelect?.(source) } const showPalette = () => { @@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (suspended() || dialog.active) return const sig = signatureFromEvent(event) + const isPalette = palette().has(sig) + const option = keymap().get(sig) + + if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return - if (palette().has(sig)) { + if (isPalette) { event.preventDefault() showPalette() return } - const option = keymap().get(sig) if (!option) return event.preventDefault() option.onSelect?.("keybind") @@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return { register, - trigger(id: string, source?: "palette" | "keybind" | "slash") { + trigger(id: string, source?: CommandSource) { run(id, source) }, keybind(id: string) { @@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, show: showPalette, keybinds(enabled: boolean) { - setStore("suspendCount", (count) => count + (enabled ? -1 : 1)) + setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) }, suspended, get catalog() { diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts index 4f223e5f8..bee5c7871 100644 --- a/packages/app/src/context/comments.test.ts +++ b/packages/app/src/context/comments.test.ts @@ -109,4 +109,45 @@ describe("comments session indexing", () => { dispose() }) }) + + test("remove keeps focus when same comment id exists in another file", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "shared", 10)], + "b.ts": [line("b.ts", "shared", 20)], + }) + + comments.setFocus({ file: "b.ts", id: "shared" }) + comments.remove("a.ts", "shared") + + expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" }) + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"]) + + dispose() + }) + }) + + test("setFocus and setActive updater callbacks receive current state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest() + + comments.setFocus({ file: "a.ts", id: "a1" }) + comments.setFocus((current) => { + expect(current).toEqual({ file: "a.ts", id: "a1" }) + return { file: "b.ts", id: "b1" } + }) + + comments.setActive({ file: "c.ts", id: "c1" }) + comments.setActive((current) => { + expect(current).toEqual({ file: "c.ts", id: "c1" }) + return null + }) + + expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" }) + expect(comments.active()).toBeNull() + + dispose() + }) + }) }) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index a88ea0d86..ecf63e45b 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -1,4 +1,4 @@ -import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" @@ -20,6 +20,19 @@ type CommentFocus = { file: string; id: string } const WORKSPACE_KEY = "__workspace__" const MAX_COMMENT_SESSIONS = 20 +function sessionKey(dir: string, id: string | undefined) { + return `${dir}\n${id ?? WORKSPACE_KEY}` +} + +function decodeSessionKey(key: string) { + const split = key.lastIndexOf("\n") + if (split < 0) return { dir: key, id: WORKSPACE_KEY } + return { + dir: key.slice(0, split), + id: key.slice(split + 1), + } +} + type CommentStore = { comments: Record<string, LineComment[]> } @@ -31,24 +44,24 @@ function aggregate(comments: Record<string, LineComment[]>) { .sort((a, b) => a.time - b.time) } -function insert(items: LineComment[], next: LineComment) { - const index = items.findIndex((item) => item.time > next.time) - if (index < 0) return [...items, next] - return [...items.slice(0, index), next, ...items.slice(index)] -} - function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) { const [state, setState] = createStore({ focus: null as CommentFocus | null, active: null as CommentFocus | null, - all: aggregate(store.comments), }) + const all = () => aggregate(store.comments) + + const setRef = ( + key: "focus" | "active", + value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null), + ) => setState(key, value) + const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => - setState("focus", value) + setRef("focus", value) const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => - setState("active", value) + setRef("active", value) const list = (file: string) => store.comments[file] ?? [] @@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor batch(() => { setStore("comments", input.file, (items) => [...(items ?? []), next]) - setState("all", (items) => insert(items, next)) setFocus({ file: input.file, id: next.id }) }) @@ -71,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor const remove = (file: string, id: string) => { batch(() => { setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) - setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id))) - setFocus((current) => (current?.id === id ? null : current)) + setFocus((current) => (current?.file === file && current.id === id ? null : current)) }) } const clear = () => { batch(() => { setStore("comments", reconcile({})) - setState("all", []) setFocus(null) setActive(null) }) @@ -87,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor return { list, - all: () => state.all, + all, add, remove, clear, focus: () => state.focus, setFocus, - clearFocus: () => setFocus(null), + clearFocus: () => setRef("focus", null), active: () => state.active, setActive, - clearActive: () => setActive(null), - reindex: () => setState("all", aggregate(store.comments)), + clearActive: () => setRef("active", null), } } @@ -117,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) { ) const session = createCommentSessionState(store, setStore) - createEffect(() => { - if (!ready()) return - session.reindex() - }) - return { ready, list: session.list, @@ -145,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont const params = useParams() 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 + const decoded = decodeSessionKey(key) return createRoot((dispose) => ({ - value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id), + value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), dispose, })) }, @@ -162,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont onCleanup(() => cache.clear()) const load = (dir: string, id: string | undefined) => { - const key = `${dir}\n${id ?? WORKSPACE_KEY}` + const key = sessionKey(dir, id) return cache.get(key).value } diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 88b70cd41..99c6d2e42 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -43,6 +43,12 @@ export { touchFileContent, } +function errorMessage(error: unknown) { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return "Unknown error" +} + export const { use: useFile, provider: FileProvider } = createSimpleContext({ name: "File", gate: false, @@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setStore("file", file, { path: file, name: getFilename(file) }) } + const setLoading = (file: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + } + + const setLoaded = (file: string, content: FileState["content"]) => { + setStore( + "file", + file, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.content = content + }), + ) + } + + const setLoadError = (file: string, message: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = false + draft.error = message + }), + ) + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: message, + }) + } + const load = (input: string, options?: { force?: boolean }) => { const file = path.normalize(input) if (!file) return Promise.resolve() @@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const pending = inflight.get(key) if (pending) return pending - setStore( - "file", - file, - produce((draft) => { - draft.loading = true - draft.error = undefined - }), - ) + setLoading(file) const promise = sdk.client.file .read({ path: file }) .then((x) => { if (scope() !== directory) return const content = x.data - setStore( - "file", - file, - produce((draft) => { - draft.loaded = true - draft.loading = false - draft.content = content - }), - ) + setLoaded(file, content) if (!content) return touchFileContent(file, approxBytes(content)) @@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }) .catch((e) => { if (scope() !== directory) return - setStore( - "file", - file, - produce((draft) => { - draft.loading = false - draft.error = e.message - }), - ) - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - description: e.message, - }) + setLoadError(file, errorMessage(e)) }) .finally(() => { inflight.delete(key) @@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return state } - 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) => { - view().setScrollTop(path.normalize(input), top) - } - - const setScrollLeft = (input: string, left: number) => { - view().setScrollLeft(path.normalize(input), left) - } - - const setSelectedLines = (input: string, range: SelectedLineRange | null) => { - view().setSelectedLines(path.normalize(input), range) + function withPath(input: string, action: (file: string) => unknown) { + return action(path.normalize(input)) } + const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file)) + const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file)) + const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file)) + const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top)) + const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left)) + const setSelectedLines = (input: string, range: SelectedLineRange | null) => + withPath(input, (file) => view().setSelectedLines(file, range)) onCleanup(() => { stop() diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index cb610bf6e..8c50a8878 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }>() type Queued = { directory: string; payload: Event } + const FLUSH_FRAME_MS = 16 + const STREAM_YIELD_MS = 8 - let queue: Array<Queued | undefined> = [] - let buffer: Array<Queued | undefined> = [] + let queue: Queued[] = [] + let buffer: Queued[] = [] const coalesced = new Map<string, number>() let timer: ReturnType<typeof setTimeout> | undefined let last = 0 @@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo last = Date.now() batch(() => { for (const event of events) { - if (!event) continue emitter.emit(event.directory, event.payload) } }) @@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const schedule = () => { if (timer) return const elapsed = Date.now() - last - timer = setTimeout(flush, Math.max(0, 16 - elapsed)) + timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) } + let streamErrorLogged = false + void (async () => { const events = await eventSdk.global.event() let yielded = Date.now() @@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (k) { const i = coalesced.get(k) if (i !== undefined) { - queue[i] = undefined + queue[i] = { directory, payload } + continue } coalesced.set(k, queue.length) } queue.push({ directory, payload }) schedule() - if (Date.now() - yielded < 8) continue + if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() await new Promise<void>((resolve) => setTimeout(resolve, 0)) } })() .finally(flush) - .catch(() => undefined) + .catch((error) => { + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[global-sdk] event stream failed", error) + }) onCleanup(() => { abort.abort() diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index e2bf44980..62c7eb66e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -47,6 +47,20 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function errorMessage(error: unknown) { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return "Unknown error" +} + +function setDevStats(value: { + activeDirectoryStores: number + evictions: number + loadSessionsFullFetchFallback: number +}) { + ;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value +} + function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() @@ -81,19 +95,11 @@ function createGlobalSync() { const updateStats = (activeDirectoryStores: number) => { if (!import.meta.env.DEV) return - ;( - globalThis as { - __OPENCODE_GLOBAL_SYNC_STATS?: { - activeDirectoryStores: number - evictions: number - loadSessionsFullFetchFallback: number - } - } - ).__OPENCODE_GLOBAL_SYNC_STATS = { + setDevStats({ activeDirectoryStores, evictions: stats.evictions, loadSessionsFullFetchFallback: stats.loadSessionsFallback, - } + }) } const paused = () => untrack(() => globalStore.reload) !== undefined @@ -204,7 +210,10 @@ function createGlobalSync() { .catch((err) => { console.error("Failed to load sessions", err) const project = getFilename(directory) - showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message }) + showToast({ + title: language.t("toast.session.listFailed.title", { project }), + description: errorMessage(err), + }) }) sessionLoads.set(directory, promise) @@ -307,12 +316,28 @@ function createGlobalSync() { void bootstrap() }) - function projectMeta(directory: string, patch: ProjectMeta) { - children.projectMeta(directory, patch) + const projectApi = { + loadSessions, + meta(directory: string, patch: ProjectMeta) { + children.projectMeta(directory, patch) + }, + icon(directory: string, value: string | undefined) { + children.projectIcon(directory, value) + }, } - function projectIcon(directory: string, value: string | undefined) { - children.projectIcon(directory, value) + const updateConfig = async (config: Config) => { + setGlobalStore("reload", "pending") + return globalSDK.client.global.config + .update({ config }) + .then(bootstrap) + .then(() => { + setGlobalStore("reload", "complete") + }) + .catch((error) => { + setGlobalStore("reload", undefined) + throw error + }) } return { @@ -326,19 +351,8 @@ function createGlobalSync() { }, child: children.child, bootstrap, - updateConfig: (config: Config) => { - setGlobalStore("reload", "pending") - return globalSDK.client.global.config.update({ config }).finally(() => { - setTimeout(() => { - setGlobalStore("reload", "complete") - }, 1000) - }) - }, - project: { - loadSessions, - meta: projectMeta, - icon: projectIcon, - }, + updateConfig, + project: projectApi, } } diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index cc4c021be..476209e41 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p const highlights = releases.slice(start, end).flatMap((release) => release.highlights) const seen = new Set<string>() const unique = highlights.filter((highlight) => { - const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join( - "\n", - ) + const key = dedupeKey(highlight) if (seen.has(key)) return false seen.add(key) return true @@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p return unique.slice(0, 5) } +function dedupeKey(highlight: Highlight) { + return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n") +} + +function loadReleaseHighlights(value: unknown, current?: string, previous?: string) { + const releases = parseChangelog(value) + if (!releases?.length) return [] + return sliceHighlights({ releases, current, previous }) +} + export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ name: "Highlights", gate: false, @@ -140,32 +148,21 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple const [from, setFrom] = createSignal<string | undefined>(undefined) const [to, setTo] = createSignal<string | undefined>(undefined) - const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined) const state = { started: false } + let timer: ReturnType<typeof setTimeout> | undefined + + const clearTimer = () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + } const markSeen = () => { if (!platform.version) return setStore("version", platform.version) } - createEffect(() => { - if (state.started) return - if (!ready()) return - if (!settings.ready()) return - if (!platform.version) return - state.started = true - - const previous = store.version - if (!previous) { - setStore("version", platform.version) - return - } - - if (previous === platform.version) return - - setFrom(previous) - setTo(platform.version) - + const start = (previous: string) => { if (!settings.general.releaseNotes()) { markSeen() return @@ -175,9 +172,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple const controller = new AbortController() onCleanup(() => { controller.abort() - const id = timer() - if (id === undefined) return - clearTimeout(id) + clearTimer() }) fetcher(CHANGELOG_URL, { @@ -187,15 +182,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple .then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined)) .then((json) => { if (!json) return - const releases = parseChangelog(json) - if (!releases) return - if (releases.length === 0) return - const highlights = sliceHighlights({ - releases, - current: platform.version, - previous, - }) - + const highlights = loadReleaseHighlights(json, platform.version, previous) if (controller.signal.aborted) return if (highlights.length === 0) { @@ -203,13 +190,33 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple return } - const timer = setTimeout(() => { + timer = setTimeout(() => { + timer = undefined markSeen() dialog.show(() => <DialogReleaseNotes highlights={highlights} />) }, 500) - setTimer(timer) }) .catch(() => undefined) + } + + createEffect(() => { + if (state.started) return + if (!ready()) return + if (!settings.ready()) return + if (!platform.version) return + state.started = true + + const previous = store.version + if (!previous) { + setStore("version", platform.version) + return + } + + if (previous === platform.version) return + + setFrom(previous) + setTo(platform.version) + start(previous) }) return { diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 22f7bcca1..a5d894e62 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [ "th", ] +const LABEL_KEY: Record<Locale, keyof Dictionary> = { + en: "language.en", + zh: "language.zh", + zht: "language.zht", + ko: "language.ko", + de: "language.de", + es: "language.es", + fr: "language.fr", + da: "language.da", + ja: "language.ja", + pl: "language.pl", + ru: "language.ru", + ar: "language.ar", + no: "language.no", + br: "language.br", + th: "language.th", + bs: "language.bs", +} + +const base = i18n.flatten({ ...en, ...uiEn }) +const DICT: Record<Locale, Dictionary> = { + en: base, + zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, + zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, + ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, + de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, + es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, + fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, + da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, + ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, + pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, + ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, + ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, + no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, + br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, + th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, + bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, +} + +const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ + { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, + { locale: "zh", match: (language) => language.startsWith("zh") }, + { locale: "ko", match: (language) => language.startsWith("ko") }, + { locale: "de", match: (language) => language.startsWith("de") }, + { locale: "es", match: (language) => language.startsWith("es") }, + { locale: "fr", match: (language) => language.startsWith("fr") }, + { locale: "da", match: (language) => language.startsWith("da") }, + { locale: "ja", match: (language) => language.startsWith("ja") }, + { locale: "pl", match: (language) => language.startsWith("pl") }, + { locale: "ru", match: (language) => language.startsWith("ru") }, + { locale: "ar", match: (language) => language.startsWith("ar") }, + { + locale: "no", + match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"), + }, + { locale: "br", match: (language) => language.startsWith("pt") }, + { locale: "th", match: (language) => language.startsWith("th") }, + { locale: "bs", match: (language) => language.startsWith("bs") }, +] + type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = { zh, @@ -102,28 +162,9 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue - if (language.toLowerCase().startsWith("zh")) { - if (language.toLowerCase().includes("hant")) return "zht" - return "zh" - } - if (language.toLowerCase().startsWith("ko")) return "ko" - if (language.toLowerCase().startsWith("de")) return "de" - if (language.toLowerCase().startsWith("es")) return "es" - if (language.toLowerCase().startsWith("fr")) return "fr" - if (language.toLowerCase().startsWith("da")) return "da" - if (language.toLowerCase().startsWith("ja")) return "ja" - if (language.toLowerCase().startsWith("pl")) return "pl" - if (language.toLowerCase().startsWith("ru")) return "ru" - if (language.toLowerCase().startsWith("ar")) return "ar" - if ( - language.toLowerCase().startsWith("no") || - language.toLowerCase().startsWith("nb") || - language.toLowerCase().startsWith("nn") - ) - return "no" - if (language.toLowerCase().startsWith("pt")) return "br" - if (language.toLowerCase().startsWith("th")) return "th" - if (language.toLowerCase().startsWith("bs")) return "bs" + const normalized = language.toLowerCase() + const match = localeMatchers.find((entry) => entry.match(normalized)) + if (match) return match.locale } return "en" @@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont }), ) - const locale = createMemo<Locale>(() => { - if (store.locale === "zh") return "zh" - if (store.locale === "zht") return "zht" - if (store.locale === "ko") return "ko" - if (store.locale === "de") return "de" - if (store.locale === "es") return "es" - if (store.locale === "fr") return "fr" - if (store.locale === "da") return "da" - if (store.locale === "ja") return "ja" - if (store.locale === "pl") return "pl" - if (store.locale === "ru") return "ru" - if (store.locale === "ar") return "ar" - if (store.locale === "no") return "no" - if (store.locale === "br") return "br" - if (store.locale === "th") return "th" - if (store.locale === "bs") return "bs" - return "en" - }) + const locale = createMemo<Locale>(() => + LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en", + ) createEffect(() => { const current = locale() @@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont setStore("locale", current) }) - const base = i18n.flatten({ ...en, ...uiEn }) - const dict = createMemo<Dictionary>(() => { - if (locale() === "en") return base - if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) } - if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) } - if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) } - if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) } - if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) } - if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) } - if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) } - if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) } - if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) } - if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) } - if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) } - if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) } - if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) } - if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) } - return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) } - }) + const dict = createMemo<Dictionary>(() => DICT[locale()]) const t = i18n.translator(dict, i18n.resolveTemplate) - const labelKey: Record<Locale, keyof Dictionary> = { - en: "language.en", - zh: "language.zh", - zht: "language.zht", - ko: "language.ko", - de: "language.de", - es: "language.es", - fr: "language.fr", - da: "language.da", - ja: "language.ja", - pl: "language.pl", - ru: "language.ru", - ar: "language.ar", - no: "language.no", - br: "language.br", - th: "language.th", - bs: "language.bs", - } - - const label = (value: Locale) => t(labelKey[value]) + const label = (value: Locale) => t(LABEL_KEY[value]) createEffect(() => { if (typeof document !== "object") return diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 4019b2f29..71f0294e7 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -11,6 +11,9 @@ import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const +const DEFAULT_PANEL_WIDTH = 344 +const DEFAULT_SESSION_WIDTH = 600 +const DEFAULT_TERMINAL_HEIGHT = 280 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { @@ -85,6 +88,14 @@ export function pruneSessionKeys(input: { .slice(input.max) } +function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { + const all = current?.all ?? [] + if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab } + if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab } + if (!all.includes(tab)) return { all: [...all, tab], active: tab } + return { all, active: tab } +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -116,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!isRecord(fileTree)) return fileTree if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree - const width = typeof fileTree.width === "number" ? fileTree.width : 344 + const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH return { ...fileTree, opened: true, - width: width === 260 ? 344 : width, + width: width === 260 ? DEFAULT_PANEL_WIDTH : width, tab: "changes", } })() @@ -151,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( createStore({ sidebar: { opened: false, - width: 344, + width: DEFAULT_PANEL_WIDTH, workspaces: {} as Record<string, boolean>, workspacesDefault: false, }, terminal: { - height: 280, + height: DEFAULT_TERMINAL_HEIGHT, opened: false, }, review: { @@ -165,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, fileTree: { opened: true, - width: 344, + width: DEFAULT_PANEL_WIDTH, tab: "changes" as "changes" | "all", }, session: { - width: 600, + width: DEFAULT_SESSION_WIDTH, }, mobileSidebar: { opened: false, @@ -184,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 - const meta = { active: undefined as string | undefined, pruned: false } - const used = new Map<string, number>() + const usage = { + active: undefined as string | undefined, + pruned: false, + used: new Map<string, number>(), + } const SESSION_STATE_KEYS = [ { key: "prompt", legacy: "prompt", version: "v2" }, @@ -214,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const drop = pruneSessionKeys({ keep, max: MAX_SESSION_KEYS, - used, + used: usage.used, view: Object.keys(store.sessionView), tabs: Object.keys(store.sessionTabs), }) @@ -233,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( dropSessionState(drop) for (const key of drop) { - used.delete(key) + usage.used.delete(key) } } function touch(sessionKey: string) { - meta.active = sessionKey - used.set(sessionKey, Date.now()) + usage.active = sessionKey + usage.used.set(sessionKey, Date.now()) if (!ready()) return - if (meta.pruned) return + if (usage.pruned) return - meta.pruned = true + usage.pruned = true prune(sessionKey) } @@ -253,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, onFlush: (sessionKey, next) => { const current = store.sessionView[sessionKey] - const keep = meta.active ?? sessionKey + const keep = usage.active ?? sessionKey if (!current) { setStore("sessionView", sessionKey, { scroll: next }) prune(keep) @@ -269,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( createEffect(() => { if (!ready()) return - if (meta.pruned) return - const active = meta.active + if (usage.pruned) return + const active = usage.active if (!active) return - meta.pruned = true + usage.pruned = true prune(active) }) @@ -546,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, fileTree: { opened: createMemo(() => store.fileTree?.opened ?? true), - width: createMemo(() => store.fileTree?.width ?? 344), + width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH), tab: createMemo(() => store.fileTree?.tab ?? "changes"), setTab(tab: "changes" | "all") { if (!store.fileTree) { - setStore("fileTree", { opened: true, width: 344, tab }) + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab }) return } setStore("fileTree", "tab", tab) }, open() { if (!store.fileTree) { - setStore("fileTree", { opened: true, width: 344, tab: "changes" }) + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) return } setStore("fileTree", "opened", true) }, close() { if (!store.fileTree) { - setStore("fileTree", { opened: false, width: 344, tab: "changes" }) + setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) return } setStore("fileTree", "opened", false) }, toggle() { if (!store.fileTree) { - setStore("fileTree", { opened: true, width: 344, tab: "changes" }) + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) return } setStore("fileTree", "opened", (x) => !x) @@ -585,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, session: { - width: createMemo(() => store.session?.width ?? 600), + width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH), resize(width: number) { if (!store.session) { setStore("session", { width }) @@ -617,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( pendingMessage: messageID, pendingMessageAt: at, }) - prune(meta.active ?? sessionKey) + prune(usage.active ?? sessionKey) return } @@ -658,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( function setTerminalOpened(next: boolean) { const current = store.terminal if (!current) { - setStore("terminal", { height: 280, opened: next }) + setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next }) return } @@ -755,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, async open(tab: string) { const session = key() - const current = store.sessionTabs[session] ?? { all: [] } - - if (tab === "review") { - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab }) - return - } - setStore("sessionTabs", session, "active", tab) - return - } - - if (tab === "context") { - const all = [tab, ...current.all.filter((x) => x !== tab)] - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all, active: tab }) - return - } - setStore("sessionTabs", session, "all", all) - setStore("sessionTabs", session, "active", tab) - return - } - - if (!current.all.includes(tab)) { - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: [tab], active: tab }) - return - } - setStore("sessionTabs", session, "all", [...current.all, tab]) - setStore("sessionTabs", session, "active", tab) - return - } - - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: current.all, active: tab }) - return - } - setStore("sessionTabs", session, "active", tab) + const next = nextSessionTabsForOpen(store.sessionTabs[session], tab) + setStore("sessionTabs", session, next) }, close(tab: string) { const session = key() diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 85f93f368..ac5da60e8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const sync = useSync() const providers = useProviders() + const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id))) function isModelValid(model: ModelKey) { const provider = providers.all().find((x) => x.id === model.providerID) - return ( - !!provider?.models[model.modelID] && - providers - .connected() - .map((p) => p.id) - .includes(model.providerID) - ) + return !!provider?.models[model.modelID] && connected().has(model.providerID) } function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { @@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } + let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined + const agent = (() => { const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [store, setStore] = createStore<{ @@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!value) return setStore("current", value.name) if (value.model) - model.set({ + setModel({ providerID: value.model.providerID, modelID: value.model.modelID, }) @@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model: {}, }) - const fallbackModel = createMemo<ModelKey | undefined>(() => { - if (sync.data.config.model) { - const [providerID, modelID] = sync.data.config.model.split("/") - if (isModelValid({ providerID, modelID })) { - return { - providerID, - modelID, - } - } - } + const resolveConfigured = () => { + if (!sync.data.config.model) return + const [providerID, modelID] = sync.data.config.model.split("/") + const key = { providerID, modelID } + if (isModelValid(key)) return key + } + const resolveRecent = () => { for (const item of models.recent.list()) { - if (isModelValid(item)) { - return item - } + if (isModelValid(item)) return item } + } + const resolveDefault = () => { const defaults = providers.default() - for (const p of providers.connected()) { - const configured = defaults[p.id] + for (const provider of providers.connected()) { + const configured = defaults[provider.id] if (configured) { - const key = { providerID: p.id, modelID: configured } + const key = { providerID: provider.id, modelID: configured } if (isModelValid(key)) return key } - const first = Object.values(p.models)[0] + const first = Object.values(provider.models)[0] if (!first) continue - const key = { providerID: p.id, modelID: first.id } + const key = { providerID: provider.id, modelID: first.id } if (isModelValid(key)) return key } + } - return undefined + const fallbackModel = createMemo<ModelKey | undefined>(() => { + return resolveConfigured() ?? resolveRecent() ?? resolveDefault() }) const current = createMemo(() => { @@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } + const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => { + batch(() => { + const currentAgent = agent.current() + const next = model ?? fallbackModel() + if (currentAgent) setEphemeral("model", currentAgent.name, next) + if (model) models.setVisibility(model, true) + if (options?.recent && model) models.recent.push(model) + }) + } + + setModel = set + return { ready: models.ready, current, recent, list: models.list, cycle, - set(model: ModelKey | undefined, options?: { recent?: boolean }) { - batch(() => { - const currentAgent = agent.current() - const next = model ?? fallbackModel() - if (currentAgent) setEphemeral("model", currentAgent.name, next) - if (model) models.setVisibility(model, true) - if (options?.recent && model) models.recent.push(model) - }) - }, + set, visible(model: ModelKey) { return models.visible(model) }, diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index fee3c10c6..12ec8371a 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -16,6 +16,12 @@ type Store = { variant?: Record<string, string | undefined> } +const RECENT_LIMIT = 5 + +function modelKey(model: ModelKey) { + return `${model.providerID}:${model.modelID}` +} + export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ name: "Models", init: () => { @@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( ), ) + const release = createMemo( + () => + new Map( + available().map((model) => { + const parsed = DateTime.fromISO(model.release_date) + return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const + }), + ), + ) + const latest = createMemo(() => pipe( available(), - filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + filter( + (x) => + Math.abs( + (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid")) + .diffNow() + .as("months"), + ) < 6, + ), groupBy((x) => x.provider.id), mapValues((models) => pipe( @@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( ), ) - const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`))) + const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x)))) const visibility = createMemo(() => { const map = new Map<string, Visibility>() @@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( function update(model: ModelKey, state: Visibility) { const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) if (index >= 0) { - setStore("user", index, { visibility: state }) + setStore("user", index, (current) => ({ ...current, visibility: state })) return } setStore("user", store.user.length, { ...model, visibility: state }) } const visible = (model: ModelKey) => { - const key = `${model.providerID}:${model.modelID}` + const key = modelKey(model) const state = visibility().get(key) if (state === "hide") return false if (state === "show") return true if (latestSet().has(key)) return true - const m = find(model) - if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true + const date = release().get(key) + if (!date?.isValid) return true return false } @@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( } const push = (model: ModelKey) => { - const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() + const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`) + if (uniq.length > RECENT_LIMIT) uniq.pop() setStore("recent", uniq) } diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index cade70a53..e35e815f9 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -18,7 +18,7 @@ import { buildNotificationIndex } from "./notification-index" type NotificationBase = { directory?: string session?: string - metadata?: any + metadata?: unknown time: number viewed: boolean } @@ -84,89 +84,93 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const index = createMemo(() => buildNotificationIndex(store.list)) - const lookup = (directory: string, sessionID?: string) => { - if (!sessionID) return Promise.resolve(undefined) + const lookup = async (directory: string, sessionID?: string) => { + if (!sessionID) return undefined const [syncStore] = globalSync.child(directory, { bootstrap: false }) const match = Binary.search(syncStore.session, sessionID, (s) => s.id) - if (match.found) return Promise.resolve(syncStore.session[match.index]) + if (match.found) return syncStore.session[match.index] return globalSDK.client.session .get({ directory, sessionID }) .then((x) => x.data) .catch(() => undefined) } + const viewedInCurrentSession = (directory: string, sessionID?: string) => { + const activeDirectory = currentDirectory() + const activeSession = currentSession() + if (!activeDirectory) return false + if (!activeSession) return false + if (!sessionID) return false + if (directory !== activeDirectory) return false + return sessionID === activeSession + } + + const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (!session) return + if (session.parentID) return + + playSound(soundSrc(settings.sounds.agent())) + + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "turn-complete", + session: sessionID, + }) + + const href = `/${base64Encode(directory)}/session/${sessionID}` + if (settings.notifications.agent()) { + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + } + }) + } + + const handleSessionError = ( + directory: string, + event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, + time: number, + ) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (session?.parentID) return + + playSound(soundSrc(settings.sounds.errors())) + + const error = "error" in event.properties ? event.properties.error : undefined + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "error", + session: sessionID ?? "global", + error, + }) + const description = + session?.title ?? + (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) + const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` + if (settings.notifications.errors()) { + void platform.notify(language.t("notification.session.error.title"), description, href) + } + }) + } + const unsub = globalSDK.event.listen((e) => { const event = e.details if (event.type !== "session.idle" && event.type !== "session.error") return const directory = e.name const time = Date.now() - const viewed = (sessionID?: string) => { - const activeDirectory = currentDirectory() - const activeSession = currentSession() - if (!activeDirectory) return false - if (!activeSession) return false - if (!sessionID) return false - if (directory !== activeDirectory) return false - return sessionID === activeSession - } - switch (event.type) { - case "session.idle": { - const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { - if (meta.disposed) return - if (!session) return - if (session.parentID) return - - playSound(soundSrc(settings.sounds.agent())) - - append({ - directory, - time, - viewed: viewed(sessionID), - type: "turn-complete", - session: sessionID, - }) - - const href = `/${base64Encode(directory)}/session/${sessionID}` - if (settings.notifications.agent()) { - void platform.notify( - language.t("notification.session.responseReady.title"), - session.title ?? sessionID, - href, - ) - } - }) - break - } - case "session.error": { - const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { - if (meta.disposed) return - if (session?.parentID) return - - playSound(soundSrc(settings.sounds.errors())) - - const error = "error" in event.properties ? event.properties.error : undefined - append({ - directory, - time, - viewed: viewed(sessionID), - type: "error", - session: sessionID ?? "global", - error, - }) - const description = - session?.title ?? - (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) - const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` - if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) - } - }) - break - } + if (event.type === "session.idle") { + handleSessionIdle(directory, event, time) + return } + handleSessionError(directory, event, time) }) onCleanup(() => { meta.disposed = true diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index a701dbd1f..988723834 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) { return false } -function hasAutoAcceptPermissionConfig(permission: unknown) { +function hasPermissionPromptRules(permission: unknown) { if (!permission) return false if (typeof permission === "string") return permission !== "allow" if (typeof permission !== "object") return false @@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const directory = decode64(params.dir) if (!directory) return false const [store] = globalSync.child(directory) - return hasAutoAcceptPermissionConfig(store.config.permission) + return hasPermissionPromptRules(store.config.permission) }) const [store, setStore, _, ready] = persisted( @@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const MAX_RESPONDED = 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map<string, number>() + const enableVersion = new Map<string, number>() function pruneResponded(now: number) { for (const [id, ts] of responded) { @@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false } + function bumpEnableVersion(sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + const next = (enableVersion.get(key) ?? 0) + 1 + enableVersion.set(key, next) + return next + } + const unsubscribe = globalSDK.event.listen((e) => { const event = e.details if (event?.type !== "permission.asked") return @@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple function enable(sessionID: string, directory: string) { const key = acceptKey(sessionID, directory) + const version = bumpEnableVersion(sessionID, directory) setStore( produce((draft) => { draft.autoAcceptEdits[key] = true @@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple globalSDK.client.permission .list({ directory }) .then((x) => { + if (enableVersion.get(key) !== version) return + if (!isAutoAccepting(sessionID, directory)) return for (const perm of x.data ?? []) { if (!perm?.id) continue if (perm.sessionID !== sessionID) continue @@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple } function disable(sessionID: string, directory?: string) { + bumpEnableVersion(sessionID, directory) const key = directory ? acceptKey(sessionID, directory) : undefined setStore( produce((draft) => { diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index e260c1977..6d4464258 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { Accessor } from "solid-js" +type PickerPaths = string | string[] | null +type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } +type OpenFilePickerOptions = { title?: string; multiple?: boolean } +type SaveFilePickerOptions = { title?: string; defaultPath?: string } +type UpdateInfo = { updateAvailable: boolean; version?: string } + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" @@ -31,19 +37,19 @@ export type Platform = { notify(title: string, description?: string, href?: string): Promise<void> /** Open directory picker dialog (native on Tauri, server-backed on web) */ - openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> + openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths> /** Open native file picker dialog (Tauri only) */ - openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> + openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths> /** Save file picker dialog (Tauri only) */ - saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null> + saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null> /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage /** Check for updates (Tauri only) */ - checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }> + checkUpdate?(): Promise<UpdateInfo> /** Install updates (Tauri only) */ update?(): Promise<void> diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 99fab6c19..064892105 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,4 +1,4 @@ -import { createStore } from "solid-js/store" +import { createStore, type SetStoreFunction } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" @@ -60,27 +60,23 @@ function isSelectionEqual(a?: FileSelection, b?: FileSelection) { ) } +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (promptA.length !== promptB.length) return false for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file") { - const fileA = partA as FileAttachmentPart - const fileB = partB as FileAttachmentPart - if (fileA.path !== fileB.path) return false - if (!isSelectionEqual(fileA.selection, fileB.selection)) return false - } - if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { - return false - } - if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { - return false - } + if (!isPartEqual(promptA[i], promptB[i])) return false } return true } @@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt { return prompt.map(clonePart) } +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) { + return `${key}:c=${item.commentID}` + } + + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function createPromptActions( + setStore: SetStoreFunction<{ + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } + }>, +) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 @@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) { }), ) - function keyForItem(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) { - return `${key}:c=${item.commentID}` - } - - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` - } + const actions = createPromptActions(setStore) return { ready, @@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) { context: { items: createMemo(() => store.context.items), add(item: ContextItem) { - const key = keyForItem(item) + const key = contextItemKey(item) if (store.context.items.find((x) => x.key === key)) return setStore("context", "items", (items) => [...items, { key, ...item }]) }, @@ -166,19 +190,8 @@ function createPromptSession(dir: string, id: string | undefined) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, }, - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, + set: actions.set, + reset: actions.reset, } } diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index 3a404ec93..555933619 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { useGlobalSDK } from "./global-sdk" import { usePlatform } from "./platform" +type SDKEventMap = { + [key in Event["type"]]: Extract<Event, { type: key }> +} + export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { directory: Accessor<string> }) => { @@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ }), ) - const emitter = createGlobalEmitter<{ - [key in Event["type"]]: Extract<Event, { type: key }> - }>() + const emitter = createGlobalEmitter<SDKEventMap>() createEffect(() => { const unsub = globalSDK.event.on(directory(), (event) => { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 351407d91..5d3d0cf3a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist" import { checkServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } +const HEALTH_POLL_INTERVAL_MS = 10_000 export function normalizeServerUrl(input: string) { const trimmed = input.trim() @@ -48,81 +49,51 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const healthy = () => state.healthy - function setActive(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - setState("active", url) - } + const defaultUrl = () => normalizeServerUrl(props.defaultUrl) - function add(input: string) { - const url = normalizeServerUrl(input) - if (!url) return + function reconcileStartup() { + const fallback = defaultUrl() + if (!fallback) return - const fallback = normalizeServerUrl(props.defaultUrl) - if (fallback && url === fallback) { + const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl) + const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list + if (!props.isSidecar) { batch(() => { - if (!store.list.includes(url)) { - // Add the fallback url to the list if it's not already in the list - setStore("list", store.list.length, url) - } - setState("active", url) + setStore("list", list) + if (store.currentSidecarUrl) setStore("currentSidecarUrl", "") + setState("active", fallback) }) return } + const nextList = list.includes(fallback) ? list : [...list, fallback] batch(() => { - if (!store.list.includes(url)) { - setStore("list", store.list.length, url) - } - setState("active", url) + setStore("list", nextList) + setStore("currentSidecarUrl", fallback) + setState("active", fallback) }) } - function remove(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - - const list = store.list.filter((x) => x !== url) - const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active - - batch(() => { - setStore("list", list) - setState("active", next) - }) - } + function updateServerList(url: string, remove = false) { + if (remove) { + const list = store.list.filter((x) => x !== url) + const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active + batch(() => { + setStore("list", list) + setState("active", next) + }) + return + } - createEffect(() => { - if (!ready()) return - if (state.active) return - const url = normalizeServerUrl(props.defaultUrl) - if (!url) return batch(() => { - // Remove the previous startup sidecar url - if (store.currentSidecarUrl) { - remove(store.currentSidecarUrl) - } - - // Add the new sidecar url - if (props.isSidecar && props.defaultUrl) { - add(props.defaultUrl) - setStore("currentSidecarUrl", props.defaultUrl) + if (!store.list.includes(url)) { + setStore("list", store.list.length, url) } - setState("active", url) }) - }) - - const isReady = createMemo(() => ready() && !!state.active) - - const fetcher = platform.fetch ?? globalThis.fetch - const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) - - createEffect(() => { - const url = state.active - if (!url) return - - setState("healthy", undefined) + } + function startHealthPolling(url: string) { let alive = true let busy = false @@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } run() - const interval = setInterval(run, 10_000) - - onCleanup(() => { + const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS) + return () => { alive = false clearInterval(interval) - }) + } + } + + function setActive(input: string) { + const url = normalizeServerUrl(input) + if (!url) return + setState("active", url) + } + + function add(input: string) { + const url = normalizeServerUrl(input) + if (!url) return + updateServerList(url) + } + + function remove(input: string) { + const url = normalizeServerUrl(input) + if (!url) return + updateServerList(url, true) + } + + createEffect(() => { + if (!ready()) return + if (state.active) return + reconcileStartup() + }) + + const isReady = createMemo(() => ready() && !!state.active) + + const fetcher = platform.fetch ?? globalThis.fetch + const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) + + createEffect(() => { + const url = state.active + if (!url) return + + setState("healthy", undefined) + onCleanup(startHealthPolling(url)) }) const origin = createMemo(() => projectsKey(state.active)) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 19b3846f8..a8efb1eac 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -85,6 +85,10 @@ export function monoFontFamily(font: string | undefined) { return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font] } +function withFallback<T>(read: () => T | undefined, fallback: T) { + return createMemo(() => read() ?? fallback) +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont return store }, general: { - autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave), + autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), setAutoSave(value: boolean) { setStore("general", "autoSave", value) }, - releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes), + releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), setReleaseNotes(value: boolean) { setStore("general", "releaseNotes", value) }, }, updates: { - startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup), + startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), setStartup(value: boolean) { setStore("updates", "startup", value) }, }, appearance: { - fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), + fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), setFontSize(value: number) { setStore("appearance", "fontSize", value) }, - font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font), + font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font), setFont(value: string) { setStore("appearance", "font", value) }, @@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("keybinds", action, keybind) }, reset(action: string) { - setStore("keybinds", action, undefined!) + setStore("keybinds", (current) => { + if (!Object.prototype.hasOwnProperty.call(current, action)) return current + const next = { ...current } + delete next[action] + return next + }) }, resetAll() { setStore("keybinds", reconcile({})) }, }, permissions: { - autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove), + autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), setAutoApprove(value: boolean) { setStore("permissions", "autoApprove", value) }, }, notifications: { - agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent), + agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), setAgent(value: boolean) { setStore("notifications", "agent", value) }, - permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions), + permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions), setPermissions(value: boolean) { setStore("notifications", "permissions", value) }, - errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors), + errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), setErrors(value: boolean) { setStore("notifications", "errors", value) }, }, sounds: { - agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent), + agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), setAgent(value: string) { setStore("sounds", "agent", value) }, - permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions), + permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), setPermissions(value: string) { setStore("sounds", "permissions", value) }, - errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors), + errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), setErrors(value: string) { setStore("sounds", "errors", value) }, diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66c53dc80..e5916598b 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) { + const pending = map.get(key) + if (pending) return pending + const promise = task().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -36,7 +50,7 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI 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)) + draft.part[input.message.id] = sortParts(input.parts) } export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { @@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR delete draft.part[input.messageID] } +function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return [input.message] + const result = Binary.search(messages, input.message.id, (m) => m.id) + const next = [...messages] + next.splice(result.index, 0, input.message) + return next + }) + setStore("part", input.message.id, sortParts(input.parts)) +} + +function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return messages + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (!result.found) return messages + const next = [...messages] + next.splice(result.index, 1) + return next + }) + setStore("part", (part: Record<string, Part[] | undefined>) => { + if (!(input.messageID in part)) return part + const next = { ...part } + delete next[input.messageID] + return next + }) +} + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const chunk = 400 + const messagePageSize = 400 const inflight = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>() @@ -81,8 +123,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } const limitFor = (count: number) => { - if (count <= chunk) return chunk - return Math.ceil(count / chunk) * chunk + if (count <= messagePageSize) return messagePageSize + return Math.ceil(count / messagePageSize) * messagePageSize + } + + const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { + const messages = await retry(() => + input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), + ) + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const session = items + .map((x) => x.info) + .filter((m) => !!m?.id) + .sort((a, b) => cmp(a.id, b.id)) + const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) + return { + session, + part, + complete: session.length < input.limit, + } } const loadMessages = async (input: { @@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (meta.loading[key]) return setMeta("loading", key, true) - await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit })) - .then((messages) => { - const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const next = items - .map((x) => x.info) - .filter((m) => !!m?.id) - .sort((a, b) => cmp(a.id, b.id)) - + await fetchMessages(input) + .then((next) => { batch(() => { - input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) - - for (const message of items) { - input.setStore( - "part", - message.info.id, - reconcile( - message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) + input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) + for (const message of next.part) { + input.setStore("part", message.id, reconcile(message.part, { key: "id" })) } - setMeta("limit", key, input.limit) - setMeta("complete", key, next.length < input.limit) + setMeta("complete", key, next.complete) }) }) .finally(() => { @@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ optimistic: { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { const [, setStore] = target(input.directory) - setStore( - produce((draft) => { - applyOptimisticAdd(draft as OptimisticStore, input) - }), - ) + setOptimisticAdd(setStore as (...args: unknown[]) => void, input) }, remove(input: { directory?: string; sessionID: string; messageID: string }) { const [, setStore] = target(input.directory) - setStore( - produce((draft) => { - applyOptimisticRemove(draft as OptimisticStore, input) - }), - ) + setOptimisticRemove(setStore as (...args: unknown[]) => void, input) }, }, addOptimisticMessage(input: { @@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ model: input.model, } const [, setStore] = target() - setStore( - produce((draft) => { - applyOptimisticAdd(draft as OptimisticStore, { - sessionID: input.sessionID, - message, - parts: input.parts, - }) - }), - ) + setOptimisticAdd(setStore as (...args: unknown[]) => void, { + sessionID: input.sessionID, + message, + parts: input.parts, + }) }, async sync(sessionID: string) { const directory = sdk.directory @@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const hasMessages = store.message[sessionID] !== undefined const hydrated = meta.limit[key] !== undefined if (hasSession && hasMessages && hydrated) return - const pending = inflight.get(key) - if (pending) return pending const count = store.message[sessionID]?.length ?? 0 - const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count) + const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count) const sessionReq = hasSession ? Promise.resolve() @@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ limit, }) - const promise = Promise.all([sessionReq, messagesReq]) - .then(() => {}) - .finally(() => { - inflight.delete(key) - }) - - inflight.set(key, promise) - return promise + return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, async diff(sessionID: string) { const directory = sdk.directory @@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (store.session_diff[sessionID] !== undefined) return const key = keyFor(directory, sessionID) - const pending = inflightDiff.get(key) - if (pending) return pending - - const promise = retry(() => client.session.diff({ sessionID })) - .then((diff) => { + return runInflight(inflightDiff, key, () => + retry(() => client.session.diff({ sessionID })).then((diff) => { setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) - }) - .finally(() => { - inflightDiff.delete(key) - }) - - inflightDiff.set(key, promise) - return promise + }), + ) }, async todo(sessionID: string) { const directory = sdk.directory @@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (store.todo[sessionID] !== undefined) return const key = keyFor(directory, sessionID) - const pending = inflightTodo.get(key) - if (pending) return pending - - const promise = retry(() => client.session.todo({ sessionID })) - .then((todo) => { + return runInflight(inflightTodo, key, () => + retry(() => client.session.todo({ sessionID })).then((todo) => { setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) - }) - .finally(() => { - inflightTodo.delete(key) - }) - - inflightTodo.set(key, promise) - return promise + }), + ) }, history: { more(sessionID: string) { @@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(sdk.directory, sessionID) return meta.loading[key] ?? false }, - async loadMore(sessionID: string, count = chunk) { + async loadMore(sessionID: string, count = messagePageSize) { const directory = sdk.directory const client = sdk.client const [, setStore] = globalSync.child(directory) @@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (meta.loading[key]) return if (meta.complete[key]) return - const currentLimit = meta.limit[key] ?? chunk + const currentLimit = meta.limit[key] ?? messagePageSize await loadMessages({ directory, client, diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index c7816158c..0e6aa08cb 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -79,19 +79,38 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str }), ) - const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { - const id = event.properties.id - if (!store.all.some((x) => x.id === id)) return + const pickNextTerminalNumber = () => { + const existingTitleNumbers = new Set( + store.all.flatMap((pty) => { + const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined + if (direct !== undefined) return [direct] + const parsed = numberFromTitle(pty.title) + if (parsed === undefined) return [] + return [parsed] + }), + ) + + return ( + Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( + (number) => !existingTitleNumbers.has(number), + ) ?? 1 + ) + } + + const removeExited = (id: string) => { + const all = store.all + const index = all.findIndex((x) => x.id === id) + if (index === -1) return + const filtered = all.filter((x) => x.id !== id) + const active = store.active === id ? filtered[0]?.id : store.active batch(() => { - setStore( - "all", - store.all.filter((x) => x.id !== id), - ) - if (store.active === id) { - const remaining = store.all.filter((x) => x.id !== id) - setStore("active", remaining[0]?.id) - } + setStore("all", filtered) + setStore("active", active) }) + } + + const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { + removeExited(event.properties.id) }) onCleanup(unsub) @@ -117,7 +136,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str return { ready, - all: createMemo(() => Object.values(store.all)), + all: createMemo(() => store.all), active: createMemo(() => store.active), clear() { batch(() => { @@ -126,20 +145,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str }) }, new() { - const existingTitleNumbers = new Set( - store.all.flatMap((pty) => { - const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined - if (direct !== undefined) return [direct] - const parsed = numberFromTitle(pty.title) - if (parsed === undefined) return [] - return [parsed] - }), - ) - - const nextNumber = - Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( - (number) => !existingTitleNumbers.has(number), - ) ?? 1 + const nextNumber = pickNextTerminalNumber() sdk.client.pty .create({ title: `Terminal ${nextNumber}` }) @@ -162,10 +168,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str }) }, update(pty: Partial<LocalPTY> & { id: string }) { - const index = store.all.findIndex((x) => x.id === pty.id) - if (index !== -1) { - setStore("all", index, (existing) => ({ ...existing, ...pty })) - } + const previous = store.all.find((x) => x.id === pty.id) + if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item))) sdk.client.pty .update({ ptyID: pty.id, @@ -173,6 +177,9 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, }) .catch((error: unknown) => { + if (previous) { + setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item))) + } console.error("Failed to update terminal", error) }) }, diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index aa52fa1e7..f041204dc 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -8,97 +8,117 @@ import pkg from "../package.json" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" -const root = document.getElementById("root") -if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - const locale = (() => { - if (typeof navigator !== "object") return "en" as const - const languages = navigator.languages?.length ? navigator.languages : [navigator.language] - for (const language of languages) { - if (!language) continue - if (language.toLowerCase().startsWith("zh")) return "zh" as const - } - return "en" as const - })() +const getLocale = () => { + if (typeof navigator !== "object") return "en" as const + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) return "zh" as const + } + return "en" as const +} +const getRootNotFoundError = () => { const key = "error.dev.rootNotFound" as const - const message = locale === "zh" ? (zh[key] ?? en[key]) : en[key] - throw new Error(message) + const locale = getLocale() + return locale === "zh" ? (zh[key] ?? en[key]) : en[key] +} + +const getStorage = (key: string) => { + if (typeof localStorage === "undefined") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +const setStorage = (key: string, value: string | null) => { + if (typeof localStorage === "undefined") return + try { + if (value !== null) { + localStorage.setItem(key, value) + return + } + localStorage.removeItem(key) + } catch { + return + } +} + +const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) +const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) + +const notify: Platform["notify"] = async (title, description, href) => { + if (!("Notification" in window)) return + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission + + if (permission !== "granted") return + + const inView = document.visibilityState === "visible" && document.hasFocus() + if (inView) return + + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + + notification.onclick = () => { + window.focus() + if (href) { + window.history.pushState(null, "", href) + window.dispatchEvent(new PopStateEvent("popstate")) + } + notification.close() + } +} + +const openLink: Platform["openLink"] = (url) => { + window.open(url, "_blank") +} + +const back: Platform["back"] = () => { + window.history.back() +} + +const forward: Platform["forward"] = () => { + window.history.forward() +} + +const restart: Platform["restart"] = async () => { + window.location.reload() +} + +const root = document.getElementById("root") +if (!(root instanceof HTMLElement) && import.meta.env.DEV) { + throw new Error(getRootNotFoundError()) } const platform: Platform = { platform: "web", version: pkg.version, - openLink(url: string) { - window.open(url, "_blank") - }, - back() { - window.history.back() - }, - forward() { - window.history.forward() - }, - restart: async () => { - window.location.reload() - }, - notify: async (title, description, href) => { - if (!("Notification" in window)) return - - const permission = - Notification.permission === "default" - ? await Notification.requestPermission().catch(() => "denied") - : Notification.permission - - if (permission !== "granted") return - - const inView = document.visibilityState === "visible" && document.hasFocus() - if (inView) return - - await Promise.resolve() - .then(() => { - const notification = new Notification(title, { - body: description ?? "", - icon: "https://opencode.ai/favicon-96x96-v3.png", - }) - notification.onclick = () => { - window.focus() - if (href) { - window.history.pushState(null, "", href) - window.dispatchEvent(new PopStateEvent("popstate")) - } - notification.close() - } - }) - .catch(() => undefined) - }, - getDefaultServerUrl: () => { - if (typeof localStorage === "undefined") return null - try { - return localStorage.getItem(DEFAULT_SERVER_URL_KEY) - } catch { - return null - } - }, - setDefaultServerUrl: (url) => { - if (typeof localStorage === "undefined") return - try { - if (url) { - localStorage.setItem(DEFAULT_SERVER_URL_KEY, url) - return - } - localStorage.removeItem(DEFAULT_SERVER_URL_KEY) - } catch { - return - } - }, + openLink, + back, + forward, + restart, + notify, + getDefaultServerUrl: readDefaultServerUrl, + setDefaultServerUrl: writeDefaultServerUrl, } -render( - () => ( - <PlatformProvider value={platform}> - <AppBaseProviders> - <AppInterface /> - </AppBaseProviders> - </PlatformProvider> - ), - root!, -) +if (root instanceof HTMLElement) { + render( + () => ( + <PlatformProvider value={platform}> + <AppBaseProviders> + <AppInterface /> + </AppBaseProviders> + </PlatformProvider> + ), + root, + ) +} diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index ad575e93b..89721f34f 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,3 +1,5 @@ +import "solid-js" + interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string @@ -6,3 +8,11 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + +declare module "solid-js" { + namespace JSX { + interface Directives { + sortable: true + } + } +} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f36bb7ab4..2dee09dfb 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,21 +1,47 @@ import { createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { DataProvider } from "@opencode-ai/ui/context" -import { iife } from "@opencode-ai/util/iife" import type { QuestionAnswer } from "@opencode-ai/sdk/v2" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const params = useParams() + const navigate = useNavigate() + const sync = useSync() + const sdk = useSDK() + + return ( + <DataProvider + data={sync.data} + directory={props.directory} + onPermissionRespond={(input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" + }) => sdk.client.permission.respond(input)} + onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)} + onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)} + onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} + onSyncSession={(sessionID: string) => sync.session.sync(sessionID)} + > + <LocalProvider>{props.children}</LocalProvider> + </DataProvider> + ) +} + export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() const language = useLanguage() - let invalid = "" + const [store, setStore] = createStore({ invalid: "" }) const directory = createMemo(() => { return decode64(params.dir) ?? "" }) @@ -23,8 +49,8 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!params.dir) return if (directory()) return - if (invalid === params.dir) return - invalid = params.dir + if (store.invalid === params.dir) return + setStore("invalid", params.dir) showToast({ variant: "error", title: language.t("common.requestFailed"), @@ -36,46 +62,7 @@ export default function Layout(props: ParentProps) { <Show when={directory()}> <SDKProvider directory={directory}> <SyncProvider> - {iife(() => { - const sync = useSync() - const sdk = useSDK() - const respond = (input: { - sessionID: string - permissionID: string - response: "once" | "always" | "reject" - }) => sdk.client.permission.respond(input) - - const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) => - sdk.client.question.reply(input) - - const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input) - - const navigateToSession = (sessionID: string) => { - navigate(`/${params.dir}/session/${sessionID}`) - } - - const sessionHref = (sessionID: string) => { - if (params.dir) return `/${params.dir}/session/${sessionID}` - return `/session/${sessionID}` - } - - const syncSession = (sessionID: string) => sync.session.sync(sessionID) - - return ( - <DataProvider - data={sync.data} - directory={directory()} - onPermissionRespond={respond} - onQuestionReply={replyToQuestion} - onQuestionReject={rejectQuestion} - onNavigateToSession={navigateToSession} - onSessionHref={sessionHref} - onSyncSession={syncSession} - > - <LocalProvider>{props.children}</LocalProvider> - </DataProvider> - ) - })} + <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider> </SyncProvider> </SDKProvider> </Show> diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 6d6faf6fa..a30d86d18 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -13,6 +13,17 @@ export type InitError = { } type Translator = ReturnType<typeof useLanguage>["t"] +const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n" + +function isIssue(value: unknown): value is { message: string; path: string[] } { + if (!value || typeof value !== "object") return false + if (!("message" in value) || !("path" in value)) return false + const message = (value as { message: unknown }).message + const path = (value as { path: unknown }).path + if (typeof message !== "string") return false + if (!Array.isArray(path)) return false + return path.every((part) => typeof part === "string") +} function isInitError(error: unknown): error is InitError { return ( @@ -112,9 +123,7 @@ function formatInitError(error: InitError, t: Translator): string { } case "ConfigInvalidError": { const issues = Array.isArray(data.issues) - ? data.issues.map( - (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."), - ) + ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) : [] const message = typeof data.message === "string" ? data.message : "" const path = typeof data.path === "string" ? data.path : safeJson(data.path) @@ -139,14 +148,14 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag if (isInitError(error)) { const message = formatInitError(error, t) if (depth > 0 && parentMessage === message) return "" - const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" return indent + `${error.name}\n${message}` } if (error instanceof Error) { const isDuplicate = depth > 0 && parentMessage === error.message const parts: string[] = [] - const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" const header = `${error.name}${error.message ? `: ${error.message}` : ""}` const stack = error.stack?.trim() @@ -190,11 +199,11 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag if (typeof error === "string") { if (depth > 0 && parentMessage === error) return "" - const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" return indent + error } - const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" return indent + safeJson(error) } @@ -212,20 +221,35 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => { const [store, setStore] = createStore({ checking: false, version: undefined as string | undefined, + actionError: undefined as string | undefined, }) async function checkForUpdates() { if (!platform.checkUpdate) return setStore("checking", true) - const result = await platform.checkUpdate() - setStore("checking", false) - if (result.updateAvailable && result.version) setStore("version", result.version) + await platform + .checkUpdate() + .then((result) => { + setStore("actionError", undefined) + if (result.updateAvailable && result.version) setStore("version", result.version) + }) + .catch((err) => { + setStore("actionError", formatError(err, language.t)) + }) + .finally(() => { + setStore("checking", false) + }) } async function installUpdate() { if (!platform.update || !platform.restart) return - await platform.update() - await platform.restart() + await platform + .update() + .then(() => platform.restart!()) + .then(() => setStore("actionError", undefined)) + .catch((err) => { + setStore("actionError", formatError(err, language.t)) + }) } return ( @@ -266,6 +290,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => { </Show> </Show> </div> + <Show when={store.actionError}> + {(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>} + </Show> <div class="flex flex-col items-center gap-2"> <div class="flex items-center justify-center gap-1"> {language.t("error.page.report.prefix")} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 6b61ed300..ba3a2b942 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -30,6 +30,13 @@ export default function Home() { .slice(0, 5) }) + const serverDotClass = createMemo(() => { + const healthy = server.healthy() + if (healthy === true) return "bg-icon-success-base" + if (healthy === false) return "bg-icon-critical-base" + return "bg-border-weak-base" + }) + function openProject(directory: string) { layout.projects.open(directory) server.projects.touch(directory) @@ -73,9 +80,7 @@ export default function Home() { <div classList={{ "size-2 rounded-full": true, - "bg-icon-success-base": server.healthy() === true, - "bg-icon-critical-base": server.healthy() === false, - "bg-border-weak-base": server.healthy() === undefined, + [serverDotClass()]: true, }} /> {server.name} @@ -115,8 +120,7 @@ export default function Home() { <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div> <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div> </div> - <div /> - <Button class="px-3" onClick={chooseProject}> + <Button class="px-3 mt-1" onClick={chooseProject}> {language.t("command.project.open")} </Button> </div> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1513752f0..aca52564b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -207,6 +207,18 @@ export default function Layout(props: ParentProps) { const setEditor = editor.setEditor const InlineEditor = editor.InlineEditor + const clearSidebarHoverState = () => { + if (layout.sidebar.opened()) return + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + + const navigateWithSidebarReset = (href: string) => { + clearSidebarHoverState() + navigate(href) + layout.mobileSidebar.hide() + } + function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) if (ids.length === 0) return @@ -252,166 +264,167 @@ export default function Layout(props: ParentProps) { setLocale(next) } - onMount(() => { - if (!platform.checkUpdate || !platform.update || !platform.restart) return - - let toastId: number | undefined - let interval: ReturnType<typeof setInterval> | undefined - - async function pollUpdate() { - const { updateAvailable, version } = await platform.checkUpdate!() - if (updateAvailable && toastId === undefined) { - toastId = showToast({ - persistent: true, - icon: "download", - title: language.t("toast.update.title"), - description: language.t("toast.update.description", { version: version ?? "" }), - actions: [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() + const useUpdatePolling = () => + onMount(() => { + if (!platform.checkUpdate || !platform.update || !platform.restart) return + + let toastId: number | undefined + let interval: ReturnType<typeof setInterval> | undefined + + const pollUpdate = () => + platform.checkUpdate!().then(({ updateAvailable, version }) => { + if (!updateAvailable) return + if (toastId !== undefined) return + toastId = showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: version ?? "" }), + actions: [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss", - }, - ], + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss", + }, + ], + }) }) - } - } - createEffect(() => { - if (!settings.ready()) return + createEffect(() => { + if (!settings.ready()) return - if (!settings.updates.startup()) { + if (!settings.updates.startup()) { + if (interval === undefined) return + clearInterval(interval) + interval = undefined + return + } + + if (interval !== undefined) return + void pollUpdate() + interval = setInterval(pollUpdate, 10 * 60 * 1000) + }) + + onCleanup(() => { if (interval === undefined) return clearInterval(interval) - interval = undefined - return - } - - if (interval !== undefined) return - void pollUpdate() - interval = setInterval(pollUpdate, 10 * 60 * 1000) + }) }) - onCleanup(() => { - if (interval === undefined) return - clearInterval(interval) - }) - }) + const useSDKNotificationToasts = () => + onMount(() => { + const toastBySession = new Map<string, number>() + const alertedAtBySession = new Map<string, number>() + const cooldownMs = 5000 - onMount(() => { - const toastBySession = new Map<string, number>() - const alertedAtBySession = new Map<string, number>() - const cooldownMs = 5000 - - const unsub = globalSDK.event.listen((e) => { - if (e.details?.type === "worktree.ready") { - setBusy(e.name, false) - WorktreeState.ready(e.name) - return + const dismissSessionAlert = (sessionKey: string) => { + const toastId = toastBySession.get(sessionKey) + if (toastId === undefined) return + toaster.dismiss(toastId) + toastBySession.delete(sessionKey) + alertedAtBySession.delete(sessionKey) } - if (e.details?.type === "worktree.failed") { - setBusy(e.name, false) - WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) - return - } + const unsub = globalSDK.event.listen((e) => { + if (e.details?.type === "worktree.ready") { + setBusy(e.name, false) + WorktreeState.ready(e.name) + return + } - if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return - const title = - e.details.type === "permission.asked" - ? language.t("notification.permission.title") - : language.t("notification.question.title") - const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) - const directory = e.name - const props = e.details.properties - if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return - - const [store] = globalSync.child(directory, { bootstrap: false }) - const session = store.session.find((s) => s.id === props.sessionID) - const sessionKey = `${directory}:${props.sessionID}` - - const sessionTitle = session?.title ?? language.t("command.session.new") - const projectName = getFilename(directory) - const description = - e.details.type === "permission.asked" - ? language.t("notification.permission.description", { sessionTitle, projectName }) - : language.t("notification.question.description", { sessionTitle, projectName }) - const href = `/${base64Encode(directory)}/session/${props.sessionID}` - - const now = Date.now() - const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 - if (now - lastAlerted < cooldownMs) return - alertedAtBySession.set(sessionKey, now) - - if (e.details.type === "permission.asked") { - playSound(soundSrc(settings.sounds.permissions())) - if (settings.notifications.permissions()) { - void platform.notify(title, description, href) + if (e.details?.type === "worktree.failed") { + setBusy(e.name, false) + WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) + return } - } - if (e.details.type === "question.asked") { - if (settings.notifications.agent()) { - void platform.notify(title, description, href) + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + const title = + e.details.type === "permission.asked" + ? language.t("notification.permission.title") + : language.t("notification.question.title") + const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) + const directory = e.name + const props = e.details.properties + if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return + + const [store] = globalSync.child(directory, { bootstrap: false }) + const session = store.session.find((s) => s.id === props.sessionID) + const sessionKey = `${directory}:${props.sessionID}` + + const sessionTitle = session?.title ?? language.t("command.session.new") + const projectName = getFilename(directory) + const description = + e.details.type === "permission.asked" + ? language.t("notification.permission.description", { sessionTitle, projectName }) + : language.t("notification.question.description", { sessionTitle, projectName }) + const href = `/${base64Encode(directory)}/session/${props.sessionID}` + + const now = Date.now() + const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 + if (now - lastAlerted < cooldownMs) return + alertedAtBySession.set(sessionKey, now) + + if (e.details.type === "permission.asked") { + playSound(soundSrc(settings.sounds.permissions())) + if (settings.notifications.permissions()) { + void platform.notify(title, description, href) + } } - } - const currentSession = params.id - if (directory === currentDir() && props.sessionID === currentSession) return - if (directory === currentDir() && session?.parentID === currentSession) return - - const existingToastId = toastBySession.get(sessionKey) - if (existingToastId !== undefined) toaster.dismiss(existingToastId) - - const toastId = showToast({ - persistent: true, - icon, - title, - description, - actions: [ - { - label: language.t("notification.action.goToSession"), - onClick: () => navigate(href), - }, - { - label: language.t("common.dismiss"), - onClick: "dismiss", - }, - ], + if (e.details.type === "question.asked") { + if (settings.notifications.agent()) { + void platform.notify(title, description, href) + } + } + + const currentSession = params.id + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return + + dismissSessionAlert(sessionKey) + + const toastId = showToast({ + persistent: true, + icon, + title, + description, + actions: [ + { + label: language.t("notification.action.goToSession"), + onClick: () => navigate(href), + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) + toastBySession.set(sessionKey, toastId) }) - toastBySession.set(sessionKey, toastId) - }) - onCleanup(unsub) - - createEffect(() => { - const currentSession = params.id - if (!currentDir() || !currentSession) return - const sessionKey = `${currentDir()}:${currentSession}` - const toastId = toastBySession.get(sessionKey) - if (toastId !== undefined) { - toaster.dismiss(toastId) - toastBySession.delete(sessionKey) - alertedAtBySession.delete(sessionKey) - } - const [store] = globalSync.child(currentDir(), { bootstrap: false }) - const childSessions = store.session.filter((s) => s.parentID === currentSession) - for (const child of childSessions) { - const childKey = `${currentDir()}:${child.id}` - const childToastId = toastBySession.get(childKey) - if (childToastId !== undefined) { - toaster.dismiss(childToastId) - toastBySession.delete(childKey) - alertedAtBySession.delete(childKey) + onCleanup(unsub) + + createEffect(() => { + const currentSession = params.id + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` + dismissSessionAlert(sessionKey) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) + const childSessions = store.session.filter((s) => s.parentID === currentSession) + for (const child of childSessions) { + dismissSessionAlert(`${currentDir()}:${child.id}`) } - } + }) }) - }) + + useUpdatePolling() + useSDKNotificationToasts() function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return @@ -641,6 +654,21 @@ export default function Layout(props: ParentProps) { return created } + const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => { + if (current.length === 0) { + return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map<string, T>() + for (const item of current) { + map.set(item.id, item) + } + for (const item of incoming) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [store, setStore] = globalSync.child(directory, { bootstrap: false }) @@ -649,51 +677,24 @@ export default function Layout(props: ParentProps) { if (prefetchToken.value !== token) return const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const next = items - .map((x) => x.info) - .filter((m) => !!m?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) + const sorted = mergeByID([], next) const current = store.message[sessionID] ?? [] - const merged = (() => { - if (current.length === 0) return next - - const map = new Map<string, Message>() - for (const item of current) { - if (!item?.id) continue - map.set(item.id, item) - } - for (const item of next) { - map.set(item.id, item) - } - return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - })() + const merged = mergeByID( + current.filter((item): item is Message => !!item?.id), + sorted, + ) batch(() => { setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { const currentParts = store.part[message.info.id] ?? [] - const mergedParts = (() => { - if (currentParts.length === 0) { - return message.parts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - } - - const map = new Map<string, (typeof currentParts)[number]>() - for (const item of currentParts) { - if (!item?.id) continue - map.set(item.id, item) - } - for (const item of message.parts) { - if (!item?.id) continue - map.set(item.id, item) - } - return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - })() + const mergedParts = mergeByID( + currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), + message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + ) setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) } @@ -1073,24 +1074,14 @@ export default function Layout(props: ParentProps) { function navigateToProject(directory: string | undefined) { if (!directory) return - if (!layout.sidebar.opened()) { - setState("hoverSession", undefined) - setState("hoverProject", undefined) - } server.projects.touch(directory) const lastSession = store.lastSession[directory] - navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) - layout.mobileSidebar.hide() + navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) } function navigateToSession(session: Session | undefined) { if (!session) return - if (!layout.sidebar.opened()) { - setState("hoverSession", undefined) - setState("hoverProject", undefined) - } - navigate(`/${base64Encode(session.directory)}/session/${session.id}`) - layout.mobileSidebar.hide() + navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`) } function openProject(directory: string, navigate = true) { @@ -1555,10 +1546,7 @@ export default function Layout(props: ParentProps) { } const createWorkspace = async (project: LocalProject) => { - if (!layout.sidebar.opened()) { - setState("hoverSession", undefined) - setState("hoverProject", undefined) - } + clearSidebarHoverState() const created = await globalSDK.client.worktree .create({ directory: project.worktree }) .then((x) => x.data) @@ -1595,8 +1583,7 @@ export default function Layout(props: ParentProps) { }) globalSync.child(created.directory) - navigate(`/${base64Encode(created.directory)}/session`) - layout.mobileSidebar.hide() + navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`) } const workspaceSidebarCtx: WorkspaceSidebarContext = { @@ -1772,14 +1759,7 @@ export default function Layout(props: ParentProps) { size="large" icon="plus-small" class="w-full" - onClick={() => { - if (!layout.sidebar.opened()) { - setState("hoverSession", undefined) - setState("hoverProject", undefined) - } - navigate(`/${base64Encode(p().worktree)}/session`) - layout.mobileSidebar.hide() - }} + onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)} > {language.t("command.session.new")} </Button> diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx index 0bbfe244c..4189e4a72 100644 --- a/packages/app/src/pages/layout/inline-editor.tsx +++ b/packages/app/src/pages/layout/inline-editor.tsx @@ -1,8 +1,9 @@ import { createStore } from "solid-js/store" -import { Show, type Accessor } from "solid-js" +import { onCleanup, Show, type Accessor } from "solid-js" import { InlineInput } from "@opencode-ai/ui/inline-input" export function createInlineEditorController() { + // This controller intentionally supports one active inline editor at a time. const [editor, setEditor] = createStore({ active: "" as string, value: "", @@ -47,6 +48,13 @@ export function createInlineEditorController() { stopPropagation?: boolean openOnDblClick?: boolean }) => { + let frame: number | undefined + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + const isEditing = () => props.editing ?? editorOpen(props.id) const stopEvents = () => props.stopPropagation ?? false const allowDblClick = () => props.openOnDblClick ?? true @@ -78,7 +86,12 @@ export function createInlineEditorController() { > <InlineInput ref={(el) => { - requestAnimationFrame(() => el.focus()) + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + if (!el.isConnected) return + el.focus() + }) }} value={editorValue()} class={props.class} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 678bfa0d8..d55090370 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -13,7 +13,7 @@ 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 { type Message, type Session, type TextPart, type UserMessage } 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" @@ -70,6 +70,116 @@ export type SessionItemProps = { archiveSession: (session: Session) => Promise<void> } +const SessionRow = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + tint: Accessor<string | undefined> + isWorking: Accessor<boolean> + hasPermissions: Accessor<boolean> + hasError: Accessor<boolean> + unseenCount: Accessor<number> + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + sidebarOpened: Accessor<boolean> + prefetchSession: (session: Session, priority?: "high" | "low") => void + scheduleHoverPrefetch: () => void + cancelHoverPrefetch: () => void +}): JSX.Element => ( + <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={props.scheduleHoverPrefetch} + onPointerLeave={props.cancelHoverPrefetch} + onMouseEnter={props.scheduleHoverPrefetch} + onMouseLeave={props.cancelHoverPrefetch} + onFocus={() => props.prefetchSession(props.session, "high")} + onClick={() => { + props.setHoverSession(undefined) + if (props.sidebarOpened()) 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: props.tint() ?? "var(--icon-interactive-base)" }} + > + <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> + <Match when={props.isWorking()}> + <Spinner class="size-[15px]" /> + </Match> + <Match when={props.hasPermissions()}> + <div class="size-1.5 rounded-full bg-surface-warning-strong" /> + </Match> + <Match when={props.hasError()}> + <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={props.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> +) + +const SessionHoverPreview = (props: { + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + hoverSession: Accessor<string | undefined> + session: Session + sidebarHovering: Accessor<boolean> + hoverReady: Accessor<boolean> + hoverMessages: Accessor<UserMessage[] | undefined> + language: ReturnType<typeof useLanguage> + isActive: Accessor<boolean> + slug: string + setHoverSession: (id: string | undefined) => void + messageLabel: (message: Message) => string | undefined + onMessageSelect: (message: Message) => void + trigger: JSX.Element +}): JSX.Element => ( + <HoverCard + openDelay={1000} + closeDelay={props.sidebarHovering() ? 600 : 0} + placement="right-start" + gutter={16} + shift={-2} + trigger={props.trigger} + mount={!props.mobile ? props.nav() : undefined} + open={props.hoverSession() === props.session.id} + onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} + > + <Show + when={props.hoverReady()} + fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} + > + <div class="overflow-y-auto max-h-72 h-full"> + <MessageNav + messages={props.hoverMessages() ?? []} + current={undefined} + getLabel={props.messageLabel} + onMessageSelect={props.onMessageSelect} + size="normal" + class="w-60" + /> + </div> + </Show> + </HoverCard> +) + export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() const navigate = useNavigate() @@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const hoverMessages = createMemo(() => - sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) @@ -141,54 +251,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { 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> + <SessionRow + session={props.session} + slug={props.slug} + mobile={props.mobile} + dense={props.dense} + tint={tint} + isWorking={isWorking} + hasPermissions={hasPermissions} + hasError={hasError} + unseenCount={unseenCount} + setHoverSession={props.setHoverSession} + clearHoverProjectSoon={props.clearHoverProjectSoon} + sidebarOpened={layout.sidebar.opened} + prefetchSession={props.prefetchSession} + scheduleHoverPrefetch={scheduleHoverPrefetch} + cancelHoverPrefetch={cancelHoverPrefetch} + /> ) return ( @@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { </Tooltip> } > - <HoverCard - openDelay={1000} - closeDelay={props.sidebarHovering() ? 600 : 0} - placement="right-start" - gutter={16} - shift={-2} + <SessionHoverPreview + mobile={props.mobile} + nav={props.nav} + hoverSession={props.hoverSession} + session={props.session} + sidebarHovering={props.sidebarHovering} + hoverReady={hoverReady} + hoverMessages={hoverMessages} + language={language} + isActive={isActive} + slug={props.slug} + setHoverSession={props.setHoverSession} + messageLabel={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")) + }} 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`} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index c91dc987d..9afa205b6 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -51,6 +51,195 @@ export const ProjectDragOverlay = (props: { ) } +const ProjectTile = (props: { + project: LocalProject + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + sidebarHovering: Accessor<boolean> + selected: Accessor<boolean> + active: Accessor<boolean> + overlay: Accessor<boolean> + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + closeProject: (directory: string) => void + setMenu: (value: boolean) => void + setOpen: (value: boolean) => void + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <ContextMenu + modal={!props.sidebarHovering()} + onOpenChange={(value) => { + props.setMenu(value) + if (value) props.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": props.selected(), + "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": + !props.selected() && !props.active(), + "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), + }} + onMouseEnter={(event: MouseEvent) => { + if (!props.overlay()) return + props.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!props.overlay()) return + props.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!props.overlay()) return + props.onProjectFocus(props.project.worktree) + }} + onClick={() => props.navigateToProject(props.project.worktree)} + onBlur={() => props.setOpen(false)} + > + <ProjectIcon project={props.project} notify /> + </ContextMenu.Trigger> + <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}> + <ContextMenu.Content> + <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}> + <ContextMenu.ItemLabel>{props.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.workspacesEnabled(props.project)} + onSelect={() => props.toggleProjectWorkspaces(props.project)} + > + <ContextMenu.ItemLabel> + {props.workspacesEnabled(props.project) + ? props.language.t("sidebar.workspaces.disable") + : props.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.closeProject(props.project.worktree)} + > + <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel> + </ContextMenu.Item> + </ContextMenu.Content> + </ContextMenu.Portal> + </ContextMenu> +) + +const ProjectPreviewPanel = (props: { + project: LocalProject + mobile?: boolean + selected: Accessor<boolean> + workspaceEnabled: Accessor<boolean> + workspaces: Accessor<string[]> + label: (directory: string) => string + projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> + projectChildren: Accessor<Map<string, string[]>> + workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> + workspaceChildren: (directory: string) => Map<string, string[]> + setOpen: (value: boolean) => void + ctx: ProjectSidebarContext + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <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={props.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={props.language.t("common.close")} + onClick={(event) => { + event.stopPropagation() + props.setOpen(false) + props.ctx.closeProject(props.project.worktree) + }} + /> + </Tooltip> + </div> + <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div> + <div class="px-2 pb-2 flex flex-col gap-2"> + <Show + when={props.workspaceEnabled()} + fallback={ + <For each={props.projectSessions()}> + {(session) => ( + <SessionItem + {...props.ctx.sessionProps} + session={session} + slug={base64Encode(props.project.worktree)} + dense + mobile={props.mobile} + popover={false} + children={props.projectChildren()} + /> + )} + </For> + } + > + <For each={props.workspaces()}> + {(directory) => { + const sessions = createMemo(() => props.workspaceSessions(directory)) + const children = createMemo(() => props.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">{props.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() + props.setOpen(false) + if (props.selected()) return + props.ctx.navigateToProject(props.project.worktree) + }} + > + {props.language.t("sidebar.project.viewAllSessions")} + </Button> + </div> + </div> +) + export const SortableProject = (props: { project: LocalProject mobile?: boolean @@ -105,177 +294,61 @@ export const SortableProject = (props: { 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> + const trigger = ( + <ProjectTile + project={props.project} + mobile={props.mobile} + nav={props.ctx.nav} + sidebarHovering={props.ctx.sidebarHovering} + selected={selected} + active={active} + overlay={overlay} + onProjectMouseEnter={props.ctx.onProjectMouseEnter} + onProjectMouseLeave={props.ctx.onProjectMouseLeave} + onProjectFocus={props.ctx.onProjectFocus} + navigateToProject={props.ctx.navigateToProject} + showEditProjectDialog={props.ctx.showEditProjectDialog} + toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} + workspacesEnabled={props.ctx.workspacesEnabled} + closeProject={props.ctx.closeProject} + setMenu={setMenu} + setOpen={setOpen} + language={language} + /> ) return ( // @ts-ignore <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <Show when={preview()} fallback={<Trigger />}> + <Show when={preview()} fallback={trigger}> <HoverCard open={open() && !menu()} openDelay={0} closeDelay={0} placement="right-start" gutter={6} - trigger={<Trigger />} + 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> + <ProjectPreviewPanel + project={props.project} + mobile={props.mobile} + selected={selected} + workspaceEnabled={workspaceEnabled} + workspaces={workspaces} + label={label} + projectSessions={projectSessions} + projectChildren={projectChildren} + workspaceSessions={workspaceSessions} + workspaceChildren={workspaceChildren} + setOpen={setOpen} + ctx={props.ctx} + language={language} + /> </HoverCard> </Show> </div> diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index ce96a09d1..23abdf157 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -34,6 +34,7 @@ export const SidebarContent = (props: { renderPanel: () => JSX.Element }): JSX.Element => { const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + const placement = () => (props.mobile ? "bottom" : "right") return ( <div class="flex h-full w-full overflow-hidden"> @@ -55,7 +56,7 @@ export const SidebarContent = (props: { <For each={props.projects()}>{(project) => props.renderProject(project)}</For> </SortableProvider> <Tooltip - placement={props.mobile ? "bottom" : "right"} + placement={placement()} value={ <div class="flex items-center gap-2"> <span>{props.openProjectLabel}</span> @@ -78,11 +79,7 @@ export const SidebarContent = (props: { </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() ?? ""} - > + <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}> <IconButton icon="settings-gear" variant="ghost" @@ -91,7 +88,7 @@ export const SidebarContent = (props: { aria-label={props.settingsLabel()} /> </TooltipKeybind> - <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}> + <Tooltip placement={placement()} value={props.helpLabel()}> <IconButton icon="help" variant="ghost" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 13c1e55ef..1d9c2e685 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -82,6 +82,222 @@ export const WorkspaceDragOverlay = (props: { ) } +const WorkspaceHeader = (props: { + local: Accessor<boolean> + busy: Accessor<boolean> + open: Accessor<boolean> + directory: string + language: ReturnType<typeof useLanguage> + branch: Accessor<string | undefined> + workspaceValue: Accessor<string> + workspaceEditActive: Accessor<boolean> + InlineEditor: WorkspaceSidebarContext["InlineEditor"] + renameWorkspace: WorkspaceSidebarContext["renameWorkspace"] + setEditor: WorkspaceSidebarContext["setEditor"] + projectId?: string +}): JSX.Element => ( + <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={props.busy()} fallback={<Icon name="branch" size="small" />}> + <Spinner class="size-[15px]" /> + </Show> + </div> + <span class="text-14-medium text-text-base shrink-0"> + {props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} : + </span> + <Show + when={!props.local()} + fallback={ + <span class="text-14-medium text-text-base min-w-0 truncate"> + {props.branch() ?? getFilename(props.directory)} + </span> + } + > + <props.InlineEditor + id={`workspace:${props.directory}`} + value={props.workspaceValue} + onSave={(next) => { + const trimmed = next.trim() + if (!trimmed) return + props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch()) + props.setEditor("value", props.workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={props.workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + </Show> + <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100"> + <Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" /> + </div> + </div> +) + +const WorkspaceActions = (props: { + directory: string + local: Accessor<boolean> + busy: Accessor<boolean> + menuOpen: Accessor<boolean> + pendingRename: Accessor<boolean> + setMenuOpen: (open: boolean) => void + setPendingRename: (value: boolean) => void + sidebarHovering: Accessor<boolean> + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + touch: Accessor<boolean> + language: ReturnType<typeof useLanguage> + workspaceValue: Accessor<string> + openEditor: WorkspaceSidebarContext["openEditor"] + showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] + showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] + root: string + setHoverSession: WorkspaceSidebarContext["setHoverSession"] + clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] + navigateToNewSession: () => void +}): JSX.Element => ( + <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": props.menuOpen(), + "opacity-0 pointer-events-none": !props.menuOpen(), + "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.sidebarHovering()} + open={props.menuOpen()} + onOpenChange={(open) => props.setMenuOpen(open)} + > + <Tooltip value={props.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={props.language.t("common.moreOptions")} + /> + </Tooltip> + <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}> + <DropdownMenu.Content + onCloseAutoFocus={(event) => { + if (!props.pendingRename()) return + event.preventDefault() + props.setPendingRename(false) + props.openEditor(`workspace:${props.directory}`, props.workspaceValue()) + }} + > + <DropdownMenu.Item + disabled={props.local()} + onSelect={() => { + props.setPendingRename(true) + props.setMenuOpen(false) + }} + > + <DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={props.local() || props.busy()} + onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)} + > + <DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={props.local() || props.busy()} + onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)} + > + <DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + <Show when={!props.touch()}> + <Tooltip value={props.language.t("command.session.new")} placement="top"> + <IconButton + icon="plus-small" + variant="ghost" + class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" + data-action="workspace-new-session" + data-workspace={base64Encode(props.directory)} + aria-label={props.language.t("command.session.new")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + props.setHoverSession(undefined) + props.clearHoverProjectSoon() + props.navigateToNewSession() + }} + /> + </Tooltip> + </Show> + </div> +) + +const WorkspaceSessionList = (props: { + slug: Accessor<string> + mobile?: boolean + ctx: WorkspaceSidebarContext + showNew: Accessor<boolean> + loading: Accessor<boolean> + sessions: Accessor<Session[]> + children: Accessor<Map<string, string[]>> + hasMore: Accessor<boolean> + loadMore: () => Promise<void> + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <nav class="flex flex-col gap-1 px-2"> + <Show when={props.showNew()}> + <NewSessionItem + slug={props.slug()} + mobile={props.mobile} + sidebarExpanded={props.ctx.sidebarExpanded} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + setHoverSession={props.ctx.setHoverSession} + /> + </Show> + <Show when={props.loading()}> + <SessionSkeleton /> + </Show> + <For each={props.sessions()}> + {(session) => ( + <SessionItem + session={session} + slug={props.slug()} + mobile={props.mobile} + children={props.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={props.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) => { + props.loadMore() + ;(e.currentTarget as HTMLButtonElement).blur() + }} + > + {props.language.t("common.loadMore")} + </Button> + </div> + </Show> + </nav> +) + export const SortableWorkspace = (props: { ctx: WorkspaceSidebarContext directory: string @@ -135,46 +351,6 @@ export const SortableWorkspace = (props: { 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> - <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100"> - <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" /> - </div> - </div> - ) - return ( <div // @ts-ignore @@ -202,7 +378,20 @@ export const SortableWorkspace = (props: { data-action="workspace-toggle" data-workspace={base64Encode(props.directory)} > - {header()} + <WorkspaceHeader + local={local} + busy={busy} + open={open} + directory={props.directory} + language={language} + branch={() => workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> </Collapsible.Trigger> } > @@ -211,139 +400,61 @@ export const SortableWorkspace = (props: { menu.open ? "pr-16" : "pr-2" } group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`} > - {header()} + <WorkspaceHeader + local={local} + busy={busy} + open={open} + directory={props.directory} + language={language} + branch={() => workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> </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> - <Show when={!touch()}> - <Tooltip value={language.t("command.session.new")} placement="top"> - <IconButton - icon="plus-small" - variant="ghost" - class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" - data-action="workspace-new-session" - data-workspace={base64Encode(props.directory)} - aria-label={language.t("command.session.new")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - props.ctx.setHoverSession(undefined) - props.ctx.clearHoverProjectSoon() - navigate(`/${slug()}/session`) - }} - /> - </Tooltip> - </Show> - </div> + <WorkspaceActions + directory={props.directory} + local={local} + busy={busy} + menuOpen={() => menu.open} + pendingRename={() => menu.pendingRename} + setMenuOpen={(open) => setMenu("open", open)} + setPendingRename={(value) => setMenu("pendingRename", value)} + sidebarHovering={props.ctx.sidebarHovering} + mobile={props.mobile} + nav={props.ctx.nav} + touch={touch} + language={language} + workspaceValue={workspaceValue} + openEditor={props.ctx.openEditor} + showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} + showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} + root={props.project.worktree} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + navigateToNewSession={() => navigate(`/${slug()}/session`)} + /> </div> </div> </div> <Collapsible.Content> - <nav class="flex flex-col gap-1 px-2"> - <Show when={showNew()}> - <NewSessionItem - slug={slug()} - mobile={props.mobile} - sidebarExpanded={props.ctx.sidebarExpanded} - clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - setHoverSession={props.ctx.setHoverSession} - /> - </Show> - <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> + <WorkspaceSessionList + slug={slug} + mobile={props.mobile} + ctx={props.ctx} + showNew={showNew} + loading={loading} + sessions={sessions} + children={children} + hasMore={hasMore} + loadMore={loadMore} + language={language} + /> </Collapsible.Content> </Collapsible> </div> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9453dd703..edcc660a0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -394,6 +394,19 @@ export default function Page() { }) } + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + async function archiveSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return @@ -411,17 +424,7 @@ export default function Page() { if (index !== -1) draft.session.splice(index, 1) }), ) - - if (params.id !== sessionID) return - if (session.parentID) { - navigate(`/${params.dir}/session/${session.parentID}`) - return - } - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - return - } - navigate(`/${params.dir}/session`) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) }) .catch((err) => { showToast({ @@ -487,16 +490,7 @@ export default function Page() { }), ) - if (params.id !== sessionID) return true - if (session.parentID) { - navigate(`/${params.dir}/session/${session.parentID}`) - return true - } - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - return true - } - navigate(`/${params.dir}/session`) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) return true } @@ -1532,15 +1526,18 @@ export default function Page() { createEffect(() => { if (!file.ready()) return setSessionHandoff(sessionKey(), { - files: Object.fromEntries( - tabs() - .all() - .flatMap((tab) => { - const path = file.pathFromTab(tab) - if (!path) return [] - return [[path, file.selectedLines(path) ?? null] as const] - }), - ), + files: tabs() + .all() + .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => { + const path = file.pathFromTab(tab) + if (!path) return acc + const selected = file.selectedLines(path) + acc[path] = + selected && typeof selected === "object" && "start" in selected && "end" in selected + ? (selected as SelectedLineRange) + : null + return acc + }, {}), }) }) @@ -1557,6 +1554,7 @@ export default function Page() { <div class="flex-1 min-h-0 flex flex-col md:flex-row"> <SessionMobileTabs open={!isDesktop() && !!params.id} + mobileTab={store.mobileTab} hasReview={hasReview()} reviewCount={reviewCount()} onSession={() => setStore("mobileTab", "session")} @@ -1719,7 +1717,6 @@ export default function Page() { dialog={dialog} file={file} comments={comments} - sync={sync} hasReview={hasReview()} reviewCount={reviewCount()} reviewTab={reviewTab()} @@ -1731,10 +1728,12 @@ export default function Page() { openTab={openTab} showAllFiles={showAllFiles} reviewPanel={reviewPanel} - messages={messages as () => unknown[]} - visibleUserMessages={visibleUserMessages as () => unknown[]} - view={view} - info={info as () => unknown} + vm={{ + messages, + visibleUserMessages, + view, + info, + }} handoffFiles={() => handoff.session.get(sessionKey())?.files} codeComponent={codeComponent} addCommentToContext={addCommentToContext} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 0c8281a66..c94c0ff35 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -12,6 +12,13 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" +const formatCommentLabel = (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}` +} + export function FileTabContent(props: { tab: string activeTab: () => string @@ -76,7 +83,6 @@ export function FileTabContent(props: { showToast({ variant: "error", title: props.language.t("toast.file.loadFailed.title"), - description: "Invalid base64 content.", }) }) const svgPreviewUrl = createMemo(() => { @@ -116,34 +122,6 @@ export function FileTabContent(props: { 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 @@ -174,8 +152,8 @@ export function FileTabContent(props: { const el = wrap const root = getRoot() if (!el || !root) { - setPositions({}) - setDraftTop(undefined) + setNote("positions", {}) + setNote("draftTop", undefined) return } @@ -186,21 +164,21 @@ export function FileTabContent(props: { next[comment.id] = markerTop(el, marker) } - setPositions(next) + setNote("positions", next) - const range = commenting() + const range = note.commenting if (!range) { - setDraftTop(undefined) + setNote("draftTop", undefined) return } const marker = findMarker(root, range) if (!marker) { - setDraftTop(undefined) + setNote("draftTop", undefined) return } - setDraftTop(markerTop(el, marker)) + setNote("draftTop", markerTop(el, marker)) } const scheduleComments = () => { @@ -213,10 +191,10 @@ export function FileTabContent(props: { }) createEffect(() => { - const range = commenting() + const range = note.commenting scheduleComments() if (!range) return - setDraft("") + setNote("draft", "") }) createEffect(() => { @@ -229,8 +207,8 @@ export function FileTabContent(props: { const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return - setOpenedComment(target.id) - setCommenting(null) + setNote("openedComment", target.id) + setNote("commenting", null) props.file.setSelectedLines(p, target.selection) requestAnimationFrame(() => props.comments.clearFocus()) }) @@ -390,16 +368,16 @@ export function FileTabContent(props: { const p = path() if (!p) return props.file.setSelectedLines(p, range) - if (!range) setCommenting(null) + if (!range) setNote("commenting", null) }} onLineSelectionEnd={(range: SelectedLineRange | null) => { if (!range) { - setCommenting(null) + setNote("commenting", null) return } - setOpenedComment(null) - setCommenting(range) + setNote("openedComment", null) + setNote("commenting", range) }} overflow="scroll" class="select-text" @@ -408,10 +386,10 @@ export function FileTabContent(props: { {(comment) => ( <LineCommentView id={comment.id} - top={positions()[comment.id]} - open={openedComment() === comment.id} + top={note.positions[comment.id]} + open={note.openedComment === comment.id} comment={comment.comment} - selection={commentLabel(comment.selection)} + selection={formatCommentLabel(comment.selection)} onMouseEnter={() => { const p = path() if (!p) return @@ -420,22 +398,22 @@ export function FileTabContent(props: { onClick={() => { const p = path() if (!p) return - setCommenting(null) - setOpenedComment((current) => (current === comment.id ? null : comment.id)) + setNote("commenting", null) + setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) props.file.setSelectedLines(p, comment.selection) }} /> )} </For> - <Show when={commenting()}> + <Show when={note.commenting}> {(range) => ( - <Show when={draftTop() !== undefined}> + <Show when={note.draftTop !== undefined}> <LineCommentEditor - top={draftTop()} - value={draft()} - selection={commentLabel(range())} - onInput={(value) => setDraft(value)} - onCancel={() => setCommenting(null)} + top={note.draftTop} + value={note.draft} + selection={formatCommentLabel(range())} + onInput={(value) => setNote("draft", value)} + onCancel={() => setNote("commenting", null)} onSubmit={(value) => { const p = path() if (!p) return @@ -445,7 +423,7 @@ export function FileTabContent(props: { comment: value, origin: "file", }) - setCommenting(null) + setNote("commenting", null) }} onPopoverFocusOut={(e: FocusEvent) => { const current = e.currentTarget as HTMLDivElement @@ -454,7 +432,7 @@ export function FileTabContent(props: { setTimeout(() => { if (!document.activeElement || !current.contains(document.activeElement)) { - setCommenting(null) + setNote("commenting", null) } }, 0) }} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a4ca06dd5..d5f04ccf9 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -9,6 +9,37 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn" import type { UserMessage } from "@opencode-ai/sdk/v2" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { + const current = target instanceof Element ? target : undefined + const nested = current?.closest("[data-scrollable]") + if (!nested || nested === root) return root + if (!(nested instanceof HTMLElement)) return root + return nested +} + +const markBoundaryGesture = (input: { + root: HTMLDivElement + target: EventTarget | null + delta: number + onMarkScrollGesture: (target?: EventTarget | null) => void +}) => { + const target = boundaryTarget(input.root, input.target) + if (target === input.root) { + input.onMarkScrollGesture(input.root) + return + } + if ( + shouldMarkBoundaryGesture({ + delta: input.delta, + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + }) + ) { + input.onMarkScrollGesture(input.root) + } +} + export function MessageTimeline(props: { mobileChanges: boolean mobileFallback: JSX.Element @@ -86,35 +117,13 @@ export function MessageTimeline(props: { 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) - } + markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchStart={(e) => { touchGesture = e.touches[0]?.clientY @@ -129,28 +138,7 @@ export function MessageTimeline(props: { 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) - } + markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) }} onTouchEnd={() => { touchGesture = undefined diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 72518c68e..634491c72 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,4 +1,5 @@ -import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js" +import { createEffect, on, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" import type { FileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" @@ -30,7 +31,7 @@ export interface SessionReviewTabProps { } export function StickyAddButton(props: { children: JSX.Element }) { - const [stuck, setStuck] = createSignal(false) + const [state, setState] = createStore({ stuck: false }) let button: HTMLDivElement | undefined createEffect(() => { @@ -43,7 +44,7 @@ export function StickyAddButton(props: { children: JSX.Element }) { const handler = () => { const rect = node.getBoundingClientRect() const scrollRect = scroll.getBoundingClientRect() - setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) + setState("stuck", rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) } scroll.addEventListener("scroll", handler, { passive: true }) @@ -60,7 +61,7 @@ export function StickyAddButton(props: { children: JSX.Element }) { <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() }} + classList={{ "border-l": state.stuck }} > {props.children} </div> @@ -78,7 +79,10 @@ export function SessionReviewTab(props: SessionReviewTabProps) { return sdk.client.file .read({ path }) .then((x) => x.data) - .catch(() => undefined) + .catch((error) => { + console.debug("[session-review] failed to read file", { path, error }) + return undefined + }) } const restoreScroll = () => { diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx index 41f058231..6afe8024a 100644 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -1,8 +1,9 @@ -import { Match, Show, Switch } from "solid-js" +import { Show } from "solid-js" import { Tabs } from "@opencode-ai/ui/tabs" export function SessionMobileTabs(props: { open: boolean + mobileTab: "session" | "changes" hasReview: boolean reviewCount: number onSession: () => void @@ -11,7 +12,7 @@ export function SessionMobileTabs(props: { }) { return ( <Show when={props.open}> - <Tabs class="h-auto"> + <Tabs value={props.mobileTab} 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")} @@ -22,12 +23,9 @@ export function SessionMobileTabs(props: { 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> + {props.hasReview + ? props.t("session.review.filesChanged", { count: props.reviewCount }) + : props.t("session.review.change.other")} </Tabs.Trigger> </Tabs.List> </Tabs> diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index eaf0564b2..8ec4f3b9f 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -1,15 +1,14 @@ -import { For, Show, type ComponentProps } from "solid-js" +import { For, Show } from "solid-js" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" 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 + questionRequest: () => QuestionRequest | undefined permissionRequest: () => { patterns: string[]; permission: string } | undefined blocked: boolean promptReady: boolean @@ -48,7 +47,7 @@ export function SessionPromptDock(props: { subtitle, }} /> - <QuestionDock request={questionDockRequest(req)} /> + <QuestionDock request={req} /> </div> ) }} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index d9460cc1a..15ad90ffe 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -21,6 +21,14 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" +import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client" + +type SessionSidePanelViewModel = { + messages: () => Message[] + visibleUserMessages: () => UserMessage[] + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> +} export function SessionSidePanel(props: { open: boolean @@ -31,7 +39,6 @@ export function SessionSidePanel(props: { dialog: ReturnType<typeof useDialog> file: ReturnType<typeof useFile> comments: ReturnType<typeof useComments> - sync: ReturnType<typeof useSync> hasReview: boolean reviewCount: number reviewTab: boolean @@ -43,10 +50,7 @@ export function SessionSidePanel(props: { openTab: (value: string) => void showAllFiles: () => void reviewPanel: () => JSX.Element - messages: () => unknown[] - visibleUserMessages: () => unknown[] - view: () => ReturnType<ReturnType<typeof useLayout>["view"]> - info: () => unknown + vm: SessionSidePanelViewModel handoffFiles: () => Record<string, SelectedLineRange | null> | undefined codeComponent: NonNullable<ValidComponent> addCommentToContext: (input: { @@ -187,10 +191,10 @@ export function SessionSidePanel(props: { <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} + messages={props.vm.messages} + visibleUserMessages={props.vm.visibleUserMessages} + view={props.vm.view} + info={props.vm.info} /> </div> </Show> @@ -203,7 +207,7 @@ export function SessionSidePanel(props: { tab={tab} activeTab={props.activeTab} tabs={props.tabs} - view={props.view} + view={props.vm.view} handoffFiles={props.handoffFiles} file={props.file} comments={props.comments} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 2e65fde0e..d3475c714 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,4 +1,4 @@ -import { createMemo, For, Show } from "solid-js" +import { 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" @@ -141,9 +141,8 @@ export function TerminalPanel(props: { <DragOverlay> <Show when={props.activeTerminalDraggable()}> {(draggedId) => { - const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - <Show when={pty()}> + <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}> {(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {terminalTabLabel({ diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index d52022d73..81c71133f 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -1,8 +1,8 @@ import { createMemo } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" -import { useCommand } from "@/context/command" +import { useCommand, type CommandOption } from "@/context/command" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useFile, selectionFromLines, type FileSelection } from "@/context/file" +import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" @@ -22,7 +22,7 @@ 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: { +export type SessionCommandContext = { command: ReturnType<typeof useCommand> dialog: ReturnType<typeof useDialog> file: ReturnType<typeof useFile> @@ -49,32 +49,48 @@ export const useSessionCommands = (input: { setActiveMessage: (message: UserMessage | undefined) => void addSelectionToContext: (path: string, selection: FileSelection) => void focusInput: () => void -}) => { +} + +const withCategory = (category: string) => { + return (option: Omit<CommandOption, "category">): CommandOption => ({ + ...option, + category, + }) +} + +export const useSessionCommands = (input: SessionCommandContext) => { + const sessionCommand = withCategory(input.language.t("command.category.session")) + const fileCommand = withCategory(input.language.t("command.category.file")) + const contextCommand = withCategory(input.language.t("command.category.context")) + const viewCommand = withCategory(input.language.t("command.category.view")) + const terminalCommand = withCategory(input.language.t("command.category.terminal")) + const modelCommand = withCategory(input.language.t("command.category.model")) + const mcpCommand = withCategory(input.language.t("command.category.mcp")) + const agentCommand = withCategory(input.language.t("command.category.agent")) + const permissionsCommand = withCategory(input.language.t("command.category.permissions")) + const sessionCommands = createMemo(() => [ - { + sessionCommand({ 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(() => [ - { + fileCommand({ 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} />), - }, - { + }), + fileCommand({ 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: () => { @@ -82,15 +98,14 @@ export const useSessionCommands = (input: { if (!active) return input.tabs().close(active) }, - }, + }), ]) const contextCommands = createMemo(() => [ - { + contextCommand({ 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(), @@ -103,7 +118,7 @@ export const useSessionCommands = (input: { const path = input.file.pathFromTab(active) if (!path) return - const range = input.file.selectedLines(path) + const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined if (!range) { showToast({ title: input.language.t("toast.context.noLineSelection.title"), @@ -114,58 +129,49 @@ export const useSessionCommands = (input: { input.addSelectionToContext(path, selectionFromLines(range)) }, - }, + }), ]) const viewCommands = createMemo(() => [ - { + viewCommand({ 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(), - }, - { + }), + viewCommand({ 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(), - }, - { + }), + viewCommand({ id: "fileTree.toggle", title: input.language.t("command.fileTree.toggle"), - description: "", - category: input.language.t("command.category.view"), keybind: "mod+\\", onSelect: () => input.layout.fileTree.toggle(), - }, - { + }), + viewCommand({ id: "input.focus", title: input.language.t("command.input.focus"), - category: input.language.t("command.category.view"), keybind: "ctrl+l", onSelect: () => input.focusInput(), - }, - { + }), + terminalCommand({ 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() }, - }, - { + }), + viewCommand({ 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, @@ -174,86 +180,78 @@ export const useSessionCommands = (input: { if (!msg) return input.setExpanded(msg.id, (open: boolean | undefined) => !open) }, - }, + }), ]) const messageCommands = createMemo(() => [ - { + sessionCommand({ 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), - }, - { + }), + sessionCommand({ 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(() => [ - { + modelCommand({ 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 />), - }, - { + }), + mcpCommand({ 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 />), - }, - { + }), + agentCommand({ 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), - }, - { + }), + agentCommand({ 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), - }, - { + }), + modelCommand({ 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(() => [ - { + permissionsCommand({ 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: () => { @@ -269,15 +267,14 @@ export const useSessionCommands = (input: { : input.language.t("toast.permissions.autoaccept.off.description"), }) }, - }, + }), ]) const sessionActionCommands = createMemo(() => [ - { + sessionCommand({ 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 () => { @@ -298,12 +295,11 @@ export const useSessionCommands = (input: { const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) input.setActiveMessage(priorMessage) }, - }, - { + }), + sessionCommand({ 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 () => { @@ -323,12 +319,11 @@ export const useSessionCommands = (input: { const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) input.setActiveMessage(priorMsg) }, - }, - { + }), + sessionCommand({ 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 () => { @@ -348,22 +343,21 @@ export const useSessionCommands = (input: { providerID: model.provider.id, }) }, - }, - { + }), + sessionCommand({ 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 [ - { + sessionCommand({ id: "session.share", title: input.info()?.share?.url ? input.language.t("session.share.copy.copyLink") @@ -371,7 +365,6 @@ export const useSessionCommands = (input: { description: input.info()?.share?.url ? input.language.t("toast.session.share.success.description") : input.language.t("command.session.share.description"), - category: input.language.t("command.category.session"), slash: "share", disabled: !input.params.id, onSelect: async () => { @@ -441,12 +434,11 @@ export const useSessionCommands = (input: { await copy(url, false) }, - }, - { + }), + sessionCommand({ 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 () => { @@ -468,7 +460,7 @@ export const useSessionCommands = (input: { }), ) }, - }, + }), ] }) diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx index a634be4b4..8e30a033a 100644 --- a/packages/app/src/utils/solid-dnd.tsx +++ b/packages/app/src/utils/solid-dnd.tsx @@ -1,55 +1,49 @@ import { useDragDropContext } from "@thisbeyond/solid-dnd" -import { JSXElement } from "solid-js" import type { Transformer } from "@thisbeyond/solid-dnd" +import { createRoot, onCleanup, type JSXElement } from "solid-js" + +type DragEvent = { draggable?: { id?: unknown } } + +const isDragEvent = (event: unknown): event is DragEvent => { + if (typeof event !== "object" || event === null) return false + return "draggable" in event +} export const 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 (!isDragEvent(event)) return undefined + const draggable = event.draggable if (!draggable) return undefined return typeof draggable.id === "string" ? draggable.id : undefined } -export const ConstrainDragXAxis = (): JSXElement => { - const context = useDragDropContext() - if (!context) return <></> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-x-axis", - order: 100, - callback: (transform) => ({ ...transform, x: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <></> -} +const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({ + id, + order: 100, + callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }), +}) -export const ConstrainDragYAxis = (): JSXElement => { +const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => { const context = useDragDropContext() - if (!context) return <></> + if (!context) return null const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) + const transformer = createTransformer(transformerId, axis) + const dispose = createRoot((dispose) => { + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return dispose }) - return <></> + onCleanup(dispose) + return null } + +export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis") + +export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis") |
