summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 09:49:14 -0600
committerGitHub <[email protected]>2026-02-12 09:49:14 -0600
commitff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch)
tree78381c67d21ef6f089647f6b19e7aa2976840dbc
parent56ad2db02055955f926fda0e4a89055b22ead6f9 (diff)
downloadopencode-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]>
-rw-r--r--packages/app/e2e/files/file-open.spec.ts19
-rw-r--r--packages/app/e2e/files/file-viewer.spec.ts39
-rw-r--r--packages/app/e2e/projects/workspace-new-session.spec.ts10
-rw-r--r--packages/app/e2e/projects/workspaces.spec.ts86
-rw-r--r--packages/app/e2e/prompt/context.spec.ts101
-rw-r--r--packages/app/e2e/prompt/prompt.spec.ts3
-rw-r--r--packages/app/e2e/session/session-undo-redo.spec.ts32
-rw-r--r--packages/app/e2e/session/session.spec.ts45
-rw-r--r--packages/app/src/app.tsx173
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx594
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx328
-rw-r--r--packages/app/src/components/dialog-edit-project.tsx67
-rw-r--r--packages/app/src/components/dialog-fork.tsx25
-rw-r--r--packages/app/src/components/dialog-manage-models.tsx27
-rw-r--r--packages/app/src/components/dialog-release-notes.tsx20
-rw-r--r--packages/app/src/components/dialog-select-directory.tsx277
-rw-r--r--packages/app/src/components/dialog-select-file.tsx330
-rw-r--r--packages/app/src/components/dialog-select-mcp.tsx45
-rw-r--r--packages/app/src/components/dialog-select-model-unpaid.tsx13
-rw-r--r--packages/app/src/components/dialog-select-model.tsx70
-rw-r--r--packages/app/src/components/dialog-select-provider.tsx18
-rw-r--r--packages/app/src/components/dialog-select-server.tsx361
-rw-r--r--packages/app/src/components/dialog-settings.tsx9
-rw-r--r--packages/app/src/components/file-tree.tsx386
-rw-r--r--packages/app/src/components/link.tsx17
-rw-r--r--packages/app/src/components/prompt-input.tsx111
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx109
-rw-r--r--packages/app/src/components/prompt-input/drag-overlay.tsx7
-rw-r--r--packages/app/src/components/prompt-input/image-attachments.tsx15
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx79
-rw-r--r--packages/app/src/components/question-dock.tsx86
-rw-r--r--packages/app/src/components/server/server-row.tsx22
-rw-r--r--packages/app/src/components/session-context-usage.tsx22
-rw-r--r--packages/app/src/components/session/session-context-breakdown.test.ts61
-rw-r--r--packages/app/src/components/session/session-context-breakdown.ts132
-rw-r--r--packages/app/src/components/session/session-context-format.ts20
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx288
-rw-r--r--packages/app/src/components/session/session-header.tsx348
-rw-r--r--packages/app/src/components/session/session-new-view.tsx4
-rw-r--r--packages/app/src/components/session/session-sortable-tab.tsx8
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx31
-rw-r--r--packages/app/src/components/settings-agents.tsx1
-rw-r--r--packages/app/src/components/settings-commands.tsx1
-rw-r--r--packages/app/src/components/settings-general.tsx530
-rw-r--r--packages/app/src/components/settings-keybinds.tsx303
-rw-r--r--packages/app/src/components/settings-mcp.tsx1
-rw-r--r--packages/app/src/components/settings-models.tsx35
-rw-r--r--packages/app/src/components/settings-permissions.tsx8
-rw-r--r--packages/app/src/components/settings-providers.tsx64
-rw-r--r--packages/app/src/components/status-popover.tsx267
-rw-r--r--packages/app/src/components/terminal.tsx192
-rw-r--r--packages/app/src/components/titlebar.tsx48
-rw-r--r--packages/app/src/context/command.tsx42
-rw-r--r--packages/app/src/context/comments.test.ts41
-rw-r--r--packages/app/src/context/comments.tsx58
-rw-r--r--packages/app/src/context/file.tsx101
-rw-r--r--packages/app/src/context/global-sdk.tsx22
-rw-r--r--packages/app/src/context/global-sync.tsx70
-rw-r--r--packages/app/src/context/highlights.tsx79
-rw-r--r--packages/app/src/context/language.tsx147
-rw-r--r--packages/app/src/context/layout.tsx105
-rw-r--r--packages/app/src/context/local.tsx72
-rw-r--r--packages/app/src/context/models.tsx39
-rw-r--r--packages/app/src/context/notification.tsx142
-rw-r--r--packages/app/src/context/permission.tsx16
-rw-r--r--packages/app/src/context/platform.tsx14
-rw-r--r--packages/app/src/context/prompt.tsx109
-rw-r--r--packages/app/src/context/sdk.tsx8
-rw-r--r--packages/app/src/context/server.tsx131
-rw-r--r--packages/app/src/context/settings.tsx35
-rw-r--r--packages/app/src/context/sync.tsx169
-rw-r--r--packages/app/src/context/terminal.tsx67
-rw-r--r--packages/app/src/entry.tsx190
-rw-r--r--packages/app/src/env.d.ts10
-rw-r--r--packages/app/src/pages/directory-layout.tsx75
-rw-r--r--packages/app/src/pages/error.tsx51
-rw-r--r--packages/app/src/pages/home.tsx14
-rw-r--r--packages/app/src/pages/layout.tsx388
-rw-r--r--packages/app/src/pages/layout/inline-editor.tsx17
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx238
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx381
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx11
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx445
-rw-r--r--packages/app/src/pages/session.tsx69
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx94
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx78
-rw-r--r--packages/app/src/pages/session/review-tab.tsx14
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx14
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx9
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx24
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx5
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx152
-rw-r--r--packages/app/src/utils/solid-dnd.tsx78
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">&quot;{props.filter}&quot;</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">&quot;{list.filter()}&quot;</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")