chore: refactor packages/app files (#13236)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
This commit is contained in:
Adam
2026-02-12 09:49:14 -06:00
committed by GitHub
parent 56ad2db020
commit ff4414bb15
93 changed files with 5391 additions and 4451 deletions

View File

@@ -1,15 +1,28 @@
import { test, expect } from "../fixtures" 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 }) => { test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await 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() const input = dialog.getByRole("textbox").first()
await input.fill("package.json") 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) await expect(dialog).toHaveCount(0)

View File

@@ -1,18 +1,41 @@
import { test, expect } from "../fixtures" 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 }) => { test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
const sep = process.platform === "win32" ? "\\" : "/" await page.locator(promptSelector).click()
const file = ["packages", "app", "package.json"].join(sep) 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 dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first() const input = dialog.getByRole("textbox").first()
await input.fill(file) await input.fill("package.json")
await clickListItem(dialog, { text: /packages.*app.*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) 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() const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible() await expect(code).toBeVisible()
await expect(code.getByText("@opencode-ai/app")).toBeVisible() await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
}) })

View File

@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click() await prompt.click()
await page.keyboard.type(text) await expect(prompt).toBeFocused()
await page.keyboard.press("Enter") 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.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()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${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 return sessionID
} }

View File

@@ -11,18 +11,12 @@ import {
cleanupTestProject, cleanupTestProject,
clickMenuItem, clickMenuItem,
confirmDialog, confirmDialog,
openProjectMenu,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
setWorkspacesEnabled, setWorkspacesEnabled,
} from "../actions" } from "../actions"
import { import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
inlineInputSelector, import { createSdk, dirSlug } from "../utils"
projectSwitchSelector,
projectWorkspacesToggleSelector,
workspaceItemSelector,
} from "../selectors"
import { dirSlug } from "../utils"
function slugFromUrl(url: string) { function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" 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") await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try { try {
await withProject( await withProject(async () => {
async () => { await page.goto(`/${nonGitSlug}/session`)
await openSidebar(page)
const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first() await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
await expect(nonGitButton).toBeVisible()
await nonGitButton.click()
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
const menu = await openProjectMenu(page, nonGitSlug) const activeDir = base64Decode(slugFromUrl(page.url()))
const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first() expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await expect(toggle).toBeVisible() await openSidebar(page)
await expect(toggle).toBeDisabled() await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) const trigger = page.locator('[data-action="project-menu"]').first()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) const hasMenu = await trigger
}, .isVisible()
{ extra: [nonGit] }, .then((x) => x)
) .catch(() => false)
if (!hasMenu) return
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 { } finally {
await cleanupTestProject(nonGit) await cleanupTestProject(nonGit)
} }
@@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => { 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) const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true }) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i) await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) 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() await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
}) })
}) })

View File

@@ -1,40 +1,95 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { withSession } from "../actions" 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 }) => { test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}` const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => { await withSession(sdk, title, async (session) => {
await sdk.session.promptAsync({ await seedContextSession({ sessionID: session.id, sdk })
sessionID: session.id,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
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) await gotoSession(session.id)
const contextButton = page const trigger = contextButton(page)
.locator('[data-component="button"]') await expect(trigger).toBeVisible()
.filter({ has: page.locator('[data-component="progress-circle"]').first() }) await trigger.click()
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() 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)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
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)
})
})

View File

@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
) )
.toContain(token) .toContain(token)
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
await expect(reply).toBeVisible({ timeout: 90_000 })
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await sdk.session.delete({ sessionID }).catch(() => undefined) await sdk.session.delete({ sessionID }).catch(() => undefined)

View File

@@ -10,21 +10,26 @@ async function seedConversation(input: {
sessionID: string sessionID: string
token: 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) const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await prompt.click() await input.sdk.session.promptAsync({
await input.page.keyboard.type(`Reply with exactly: ${input.token}`) sessionID: input.sessionID,
await input.page.keyboard.press("Enter") noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined let userMessageID: string | undefined
await expect await expect
.poll( .poll(
async () => { async () => {
const messages = await input.sdk.session const users = (await messages()).filter(
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter(
(m) => (m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" && m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)), 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] const user = users[users.length - 1]
if (!user) return false if (!user) return false
userMessageID = user.info.id userMessageID = user.info.id
return true
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)
}, },
{ timeout: 90_000 }, { timeout: 90_000, intervals: [250, 500, 1_000] },
) )
.toBe(true) .toBe(true)
if (!userMessageID) throw new Error("Expected a user message id") 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 } return { prompt, userMessageID }
} }

View File

@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const newTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => { await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id) await seedMessage(sdk, session.id)
await gotoSession(session.id) await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first() const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible() 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 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 seedMessage(sdk, session.id)
await gotoSession(session.id) await gotoSession(session.id)
const { rightSection, popoverBody } = await openSharePopover(page) const shared = await openSharePopover(page)
await popoverBody.getByRole("button", { name: "Publish" }).first().click() 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 await expect
.poll( .poll(
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
) )
.not.toBeUndefined() .not.toBeUndefined()
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first() const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(copyButton).toBeVisible({ timeout: 30_000 })
const sharedPopover = await openSharePopover(page)
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
) )
.toBeUndefined() .toBeUndefined()
await expect(copyButton).not.toBeVisible({ timeout: 30_000 }) const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
const unsharedPopover = await openSharePopover(page)
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
}) })

View File

@@ -1,5 +1,5 @@
import "@/index.css" 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 { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> 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) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> 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> 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) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( if (!server.url) return null
<Show when={server.url} keyed> return props.children
{props.children}
</Show>
)
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform() const platform = usePlatform()
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const stored = (() => { const defaultServerUrl = resolveDefaultServerUrl({
if (platform.platform !== "web") return defaultUrl: props.defaultUrl,
const result = platform.getDefaultServerUrl?.() storedDefaultServerUrl,
if (result instanceof Promise) return hostname: location.hostname,
if (!result) return origin: window.location.origin,
return normalizeServerUrl(result) isDev: import.meta.env.DEV,
})() devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
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
}
return ( return (
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}> <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Router <Router
root={(routerProps) => ( root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>
{props.children}
{routerProps.children}
</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)}
> >
<Route <Route path="/" component={HomeRoute} />
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={SessionIndexRoute} />
<Route <Route path="/session/:id?" component={SessionRoute} />
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> </Route>
</Router> </Router>
</GlobalSyncProvider> </GlobalSyncProvider>

View File

@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { iife } from "@opencode-ai/util/iife"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, 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 method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
@@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
const method = methods()[index] const method = methods()[index]
setStore( dispatch({ type: "method.select", index })
produce((draft) => {
draft.methodIndex = index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
}),
)
if (method.type === "oauth") { if (method.type === "oauth") {
setStore("state", "pending") dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
.authorize( .authorize(
@@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = setTimeout(() => { timer.current = setTimeout(() => {
timer.current = undefined timer.current = undefined
if (!alive.value) return if (!alive.value) return
setStore("state", "complete") dispatch({ type: "auth.complete", authorization: x.data! })
setStore("authorization", x.data!)
}, delay) }, delay)
return return
} }
setStore("state", "complete") dispatch({ type: "auth.complete", authorization: x.data! })
setStore("authorization", x.data!)
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
setStore("state", "error") dispatch({ type: "auth.error", error: String(e) })
setStore("error", String(e))
}) })
} }
} }
@@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) {
if (methods().length === 1) { if (methods().length === 1) {
selectMethod(0) selectMethod(0)
} }
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
}) })
async function complete() { async function complete() {
@@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) {
return return
} }
if (store.authorization) { if (store.authorization) {
setStore("authorization", undefined) dispatch({ type: "method.reset" })
setStore("methodIndex", undefined)
return return
} }
if (store.methodIndex) { if (store.methodIndex !== undefined) {
setStore("methodIndex", undefined) dispatch({ type: "method.reset" })
return return
} }
dialog.show(() => <DialogSelectProvider />) 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 ( return (
<Dialog <Dialog
title={ title={
@@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
<div class="px-2.5 pb-10 flex flex-col gap-6"> <div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch> <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Match when={store.methodIndex === undefined}> <Switch>
<div class="text-14-regular text-text-base"> <Match when={store.methodIndex === undefined}>
{language.t("provider.connect.selectMethod", { provider: provider().name })} <MethodSelection />
</div> </Match>
<div class=""> <Match when={store.state === "pending"}>
<List <div class="text-14-regular text-text-base">
ref={(ref) => { <div class="flex items-center gap-x-2">
listRef = ref <Spinner />
}} <span>{language.t("provider.connect.status.inProgress")}</span>
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>
</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>
</div> </div>
) </div>
})} </Match>
</Match> <Match when={store.state === "error"}>
<Match when={method()?.type === "oauth"}> <div class="text-14-regular text-text-base">
<Switch> <div class="flex items-center gap-x-2">
<Match when={store.authorization?.method === "code"}> <Icon name="circle-ban-sign" class="text-icon-critical-base" />
{iife(() => { <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
const [formStore, setFormStore] = createStore({ </div>
value: "", </div>
error: undefined as string | undefined, </Match>
}) <Match when={method()?.type === "api"}>
<ApiAuthView />
onMount(() => { </Match>
if (store.authorization?.method === "code" && store.authorization?.url) { <Match when={method()?.type === "oauth"}>
platform.openLink(store.authorization.url) <Switch>
} <Match when={store.authorization?.method === "code"}>
}) <OAuthCodeView />
</Match>
async function handleSubmit(e: SubmitEvent) { <Match when={store.authorization?.method === "auto"}>
e.preventDefault() <OAuthAutoView />
</Match>
const form = e.currentTarget as HTMLFormElement </Switch>
const formData = new FormData(form) </Match>
const code = formData.get("code") as string </Switch>
</div>
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> </div>
</div> </div>
</Dialog> </Dialog>

View File

@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js" import { For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" 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 PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" 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 = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
} }
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore({ const [form, setForm] = createStore<FormState>({
providerID: "", providerID: "",
name: "", name: "",
baseURL: "", baseURL: "",
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
saving: false, saving: false,
}) })
const [errors, setErrors] = createStore({ const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined as string | undefined, providerID: undefined,
name: undefined as string | undefined, name: undefined,
baseURL: undefined as string | undefined, baseURL: undefined,
models: [{} as { id?: string; name?: string }], models: [{}],
headers: [{} as { key?: string; value?: string }], headers: [{}],
}) })
const goBack = () => { const goBack = () => {
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
} }
const addModel = () => { const addModel = () => {
setForm( setForm("models", (v) => [...v, { id: "", name: "" }])
"models", setErrors("models", (v) => [...v, {}])
produce((draft) => {
draft.push({ id: "", name: "" })
}),
)
setErrors(
"models",
produce((draft) => {
draft.push({})
}),
)
} }
const removeModel = (index: number) => { const removeModel = (index: number) => {
if (form.models.length <= 1) return if (form.models.length <= 1) return
setForm( setForm("models", (v) => v.filter((_, i) => i !== index))
"models", setErrors("models", (v) => v.filter((_, i) => i !== index))
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
} }
const addHeader = () => { const addHeader = () => {
setForm( setForm("headers", (v) => [...v, { key: "", value: "" }])
"headers", setErrors("headers", (v) => [...v, {}])
produce((draft) => {
draft.push({ key: "", value: "" })
}),
)
setErrors(
"headers",
produce((draft) => {
draft.push({})
}),
)
} }
const removeHeader = (index: number) => { const removeHeader = (index: number) => {
if (form.headers.length <= 1) return if (form.headers.length <= 1) return
setForm( setForm("headers", (v) => v.filter((_, i) => i !== index))
"headers", setErrors("headers", (v) => v.filter((_, i) => i !== index))
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
} }
const validate = () => { const validate = () => {
const providerID = form.providerID.trim() const output = validateCustomProvider({
const name = form.name.trim() form,
const baseURL = form.baseURL.trim() t: language.t,
const apiKey = form.apiKey.trim() disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
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 modelsValid = modelErrors.every((m) => !m.id && !m.name) setErrors(output.errors)
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) return output.result
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,
},
}
} }
const save = async (e: SubmitEvent) => { const save = async (e: SubmitEvent) => {
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={setForm.bind(null, "providerID")} onChange={(v) => setForm("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined} validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID} error={errors.providerID}
/> />
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.name.label")} label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")} placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={setForm.bind(null, "name")} onChange={(v) => setForm("name", v)}
validationState={errors.name ? "invalid" : undefined} validationState={errors.name ? "invalid" : undefined}
error={errors.name} error={errors.name}
/> />
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.baseURL.label")} label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")} placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={setForm.bind(null, "baseURL")} onChange={(v) => setForm("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined} validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL} error={errors.baseURL}
/> />
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.apiKey.placeholder")} placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")} description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={setForm.bind(null, "apiKey")} onChange={(v) => setForm("apiKey", v)}
/> />
</div> </div>

View File

@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconHover: false, iconHover: false,
}) })
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) { function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return if (!file.type.startsWith("image/")) return
const reader = new FileReader() const reader = new FileReader()
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
setStore("saving", true) await Promise.resolve()
const name = store.name.trim() === folderName() ? "" : store.name.trim() .then(async () => {
const start = store.startup.trim() 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") { if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({ await globalSDK.client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color, override: store.iconUrl }, icon: { color: store.color, override: store.iconUrl },
commands: { start }, 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)
}) })
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
setStore("saving", false)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
setStore("saving", false)
dialog.close()
} }
return ( return (
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (store.iconUrl && store.iconHover) { if (store.iconUrl && store.iconHover) {
clearIcon() clearIcon()
} else { } 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" /> <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div> </div>
</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"> <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.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span> <span>{language.t("dialog.project.edit.icon.recommended")}</span>

View File

@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
dialog.close() sdk.client.session
.fork({ sessionID, messageID: item.id })
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => { .then((forked) => {
if (!forked.data) return if (!forked.data) {
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) showToast({ title: language.t("common.requestFailed") })
requestAnimationFrame(() => { return
prompt.set(restored) }
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 ( return (

View File

@@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => {
const handleConnectProvider = () => { const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider />)
} }
const providerRank = (id: string) => popularProviders.indexOf(id)
return ( return (
<Dialog <Dialog
@@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => {
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name} groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => { sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id const aRank = providerRank(a.items[0].provider.id)
const bProvider = b.items[0].provider.id const bRank = providerRank(b.items[0].provider.id)
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 const aPopular = aRank >= 0
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 const bPopular = bRank >= 0
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}} }}
onSelect={(x) => { onSelect={(x) => {
if (!x) return if (!x) return
const visible = local.model.visible({ const key = { modelID: x.id, providerID: x.provider.id }
modelID: x.id, local.model.setVisibility(key, !local.model.visible(key))
providerID: x.provider.id,
})
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
}} }}
> >
{(i) => ( {(i) => (
@@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => {
<span>{i.name}</span> <span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
checked={ checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
!!local.model.visible({
modelID: i.id,
providerID: i.provider.id,
})
}
onChange={(checked) => { onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}} }}

View File

@@ -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 { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
handleClose() handleClose()
} }
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() 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 ( return (
<Dialog <Dialog
size="large" size="large"
fit fit
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden" 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 class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
<div class="flex flex-1 min-w-0 min-h-0">
{/* Left side - Text content */} {/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8"> <div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */} {/* Top section - feature content (fixed position from top) */}

View File

@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -21,157 +21,131 @@ type Row = {
search: string search: string
} }
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { function cleanInput(value: string) {
const sync = useGlobalSync() const first = (value ?? "").split(/\r?\n/)[0] ?? ""
const sdk = useGlobalSDK() return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
const dialog = useDialog() }
const language = useLanguage()
const [filter, setFilter] = createSignal("") function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
let list: ListRef | undefined function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) 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(/\/+$/, "")
}
const [fallbackPath] = createResource( function joinPath(base: string | undefined, rel: string) {
() => (missingBase() ? true : undefined), const b = trimTrailing(base ?? "")
async () => { const r = trimTrailing(rel).replace(/^\/+/, "")
return sdk.client.path if (!b) return r
.get() if (!r) return b
.then((x) => x.data) if (b.endsWith("/")) return b + r
.catch(() => undefined) return b + "/" + r
}, }
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") 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 ""
}
const start = createMemo( function parentOf(input: string) {
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, 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)
}
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 ""
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 ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
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 + "/"
}
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 }>>>() const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const clean = (value: string) => { const scoped = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? "" const base = args.start()
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
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 = 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 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 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)
}
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 display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || 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 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 }
}
function scoped(value: string) {
const base = start()
if (!base) return if (!base) return
const raw = normalizeDriveRoot(value) const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" } if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home() const h = args.home()
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" } if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) } if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw) const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw } return { directory: trimTrailing(base), path: raw }
} }
async function dirs(dir: string) { const dirs = async (dir: string) => {
const key = trimTrailing(dir) const key = trimTrailing(dir)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = sdk.client.file const request = args.sdk.client.file
.list({ directory: key, path: "" }) .list({ directory: key, path: "" })
.then((x) => x.data ?? []) .then((x) => x.data ?? [])
.catch(() => []) .catch(() => [])
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return request 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) const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute) if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
} }
const directories = async (filter: string) => { return async (filter: string) => {
const value = clean(filter) const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value) const scopedInput = scoped(value)
if (!scopedInput) return [] as string[] if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value) const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path) const query = normalizeDriveRoot(scopedInput.path)
const find = () => const find = () =>
sdk.client.find args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? []) .then((x) => x.data ?? [])
.catch(() => []) .catch(() => [])
if (!isPath) { if (!isPath) {
const results = await find() const results = await find()
if (!active()) return []
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const branch = 4 const branch = 4
let paths = [scopedInput.directory] let paths = [scopedInput.directory]
for (const part of head) { for (const part of head) {
if (!active()) return []
if (part === "..") { if (part === "..") {
paths = paths.map(parentOf) paths = paths.map(parentOf)
continue continue
} }
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() 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) paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[] if (paths.length === 0) return [] as string[]
} }
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out)) const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/") const expand = !raw.endsWith("/")
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
if (!target) return deduped.slice(0, 50) if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30) const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children])) const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) 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 items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
return results.map(row) return results.map((absolute) => toRow(absolute, home()))
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
key={(x) => x.absolute} key={(x) => x.absolute}
filterKeys={["search"]} filterKeys={["search"]}
ref={(r) => (list = r)} ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))} onFilter={(value) => setFilter(cleanInput(value))}
onKeyEvent={(e, item) => { onKeyEvent={(e, item) => {
if (e.key !== "Tab") return if (e.key !== "Tab") return
if (e.shiftKey) return if (e.shiftKey) return
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const value = display(item.absolute, filter()) const value = displayPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/") list?.setFilter(value.endsWith("/") ? value : value + "/")
}} }}
onSelect={(path) => { onSelect={(path) => {
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}} }}
> >
{(item) => { {(item) => {
const path = display(item.absolute, filter()) const path = displayPath(item.absolute, filter(), home())
if (path === "~") { if (path === "~") {
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">

View File

@@ -36,6 +36,223 @@ type Entry = {
type DialogSelectFileMode = "all" | "files" type DialogSelectFileMode = "all" | "files"
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 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
},
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(() => {
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<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, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
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 = 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 = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
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, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
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) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
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 ?? 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
updated?: number
}[],
)
}),
)
.then((results) => {
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) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false) const [grouped, setGrouped] = createSignal(false)
const common = [ const commandEntries = createCommandEntries({ filesOnly, command, language })
"session.new", const fileEntries = createFileEntries({ file, tabs, language })
"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 projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => { const project = createMemo(() => {
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return `${kind} : ${name || path}` return `${kind} : ${name || path}`
} }
const sessionItem = (input: { const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
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,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, 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 recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const root = createMemo(() => {
const nodes = 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)
})
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
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return 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"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const items = async (text: string) => { const items = async (text: string) => {
const query = text.trim() const query = text.trim()
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (!query && filesOnly()) { if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("") 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) { if (loaded || next.length > 0) {
void pending void pending
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
} }
await pending 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()) { if (filesOnly()) {
const files = await file.searchFiles(query) 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 [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem) const category = language.t("palette.group.files")
return [...list(), ...nextSessions, ...entries] const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
} }
const handleMove = (item: Entry | undefined) => { const handleMove = (item: Entry | undefined) => {

View File

@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" 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 = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
const toggle = async (name: string) => { const toggle = async (name: string) => {
if (loading()) return if (loading()) return
setLoading(name) setLoading(name)
const status = sync.data.mcp[name] try {
if (status?.status === "connected") { const status = sync.data.mcp[name]
await sdk.client.mcp.disconnect({ name }) if (status?.status === "connected") {
} else { await sdk.client.mcp.disconnect({ name })
await sdk.client.mcp.connect({ 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) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
{(i) => { {(i) => {
const mcpStatus = () => sync.data.mcp[i.name] const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status 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 error = () => {
const s = mcpStatus() const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined 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 flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="truncate">{i.name}</span> <span class="truncate">{i.name}</span>
<Show when={status() === "connected"}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</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> </Show>
<Show when={loading() === i.name}> <Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>

View File

@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { Tooltip } from "@opencode-ai/ui/tooltip" 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 { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
const language = useLanguage() const language = useLanguage()
let listRef: ListRef | undefined let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return if (e.key === "Escape") return
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return ( return (
<Dialog <Dialog
title={language.t("dialog.model.select.title")} title={language.t("dialog.model.select.title")}
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none" 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> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"

View File

@@ -1,5 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover" 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 { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
@@ -54,13 +57,7 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
value={ value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
> >
{node} {node}
</Tooltip> </Tooltip>
@@ -75,7 +72,7 @@ const ModelList: Component<{
{(i) => ( {(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular"> <div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span> <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> <Tag>{language.t("model.tag.free")}</Tag>
</Show> </Show>
<Show when={i.latest}> <Show when={i.latest}>
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
dismiss: "escape" | "outside" | null dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({ }>({
open: false, open: false,
dismiss: null, dismiss: null,
trigger: undefined,
content: undefined,
}) })
const dialog = useDialog() const dialog = useDialog()
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
} }
const language = useLanguage() 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 ( return (
<Kobalte <Kobalte
open={store.open} open={store.open}
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
placement="top-start" placement="top-start"
gutter={8} 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} {props.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <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" 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) => { onEscapeKeyDown={(event) => {
setStore("dismiss", "escape") setStore("dismiss", "escape")

View File

@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") 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 ( return (
<Dialog title={language.t("command.provider.connect")} transition> <Dialog title={language.t("command.provider.connect")} transition>
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id} key={(x) => x?.id}
items={() => { items={() => {
language.locale() language.locale()
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()] return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}} }}
filterKeys={["id", "name"]} filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
<Show when={i.id === "opencode"}> <Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
<Show when={i.id === "anthropic"}> <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<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>
</div> </div>
)} )}
</List> </List>

View File

@@ -38,6 +38,64 @@ interface EditRowProps {
onBlur: () => void 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) { function AddRow(props: AddRowProps) {
return ( return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> <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 platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() 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({ const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>, status: {} as Record<string, ServerHealth | undefined>,
addServer: { addServer: {
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
status: undefined as boolean | undefined, 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 = () => { const resetAdd = () => {
setStore("addServer", { setStore("addServer", {
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
} }
const scrollListToBottom = () => { 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 if (!scroll) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight scroll.scrollTop = scroll.scrollHeight
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
return ( return (
<Dialog title={language.t("dialog.server.title")}> <Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<List <div ref={(el) => (listRoot = el)}>
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} <List
noInitialSelection search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
emptyMessage={language.t("dialog.server.empty")} noInitialSelection
items={sortedItems} emptyMessage={language.t("dialog.server.empty")}
key={(x) => x} items={sortedItems}
onSelect={(x) => { key={(x) => x}
if (x) select(x) onSelect={(x) => {
}} if (x) select(x)
onFilter={(value) => { }}
if (value && store.addServer.showForm && !store.addServer.adding) { onFilter={(value) => {
resetAdd() 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" divider={true}
add={ 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"
store.addServer.showForm add={
? { store.addServer.showForm
render: () => ( ? {
<AddRow render: () => (
value={store.addServer.url} <AddRow
placeholder={language.t("dialog.server.add.placeholder")} value={store.addServer.url}
adding={store.addServer.adding} placeholder={language.t("dialog.server.add.placeholder")}
error={store.addServer.error} adding={store.addServer.adding}
status={store.addServer.status} error={store.addServer.error}
onChange={handleAddChange} status={store.addServer.status}
onKeyDown={handleAddKey} onChange={handleAddChange}
onBlur={blurAdd} 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)}
/>
}
>
<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>
<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 : undefined
onSelect={() => { }
setStore("editServer", { >
id: i, {(i) => {
value: i, return (
error: "", <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
status: store.status[i]?.healthy, <Show
}) when={store.editServer.id !== i}
}} fallback={
> <EditRow
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> value={store.editServer.value}
</DropdownMenu.Item> placeholder={language.t("dialog.server.add.placeholder")}
<Show when={canDefault() && defaultUrl() !== i}> 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>
}
/>
</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 <DropdownMenu.Item
onSelect={async () => { onSelect={() => {
try { setStore("editServer", {
await platform.setDefaultServerUrl?.(i) id: i,
defaultUrlActions.mutate(i) value: i,
} catch (err) { error: "",
showToast({ status: store.status[i]?.healthy,
variant: "error", })
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}} }}
> >
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </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 <DropdownMenu.Item
onSelect={async () => { onSelect={() => handleRemove(i)}
try { class="text-text-on-critical-base hover:bg-surface-critical-weak"
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),
})
}
}}
> >
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </DropdownMenu.Content>
<DropdownMenu.Separator /> </DropdownMenu.Portal>
<DropdownMenu.Item </DropdownMenu>
onSelect={() => handleRemove(i)} </div>
class="text-text-on-critical-base hover:bg-surface-critical-weak" </Show>
> </div>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> )
</DropdownMenu.Item> }}
</DropdownMenu.Content> </List>
</DropdownMenu.Portal> </div>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
<div class="px-5 pb-5"> <div class="px-5 pb-5">
<Button <Button

View File

@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar"> <Tabs.Content value="models" class="no-scrollbar">
<SettingsModels /> <SettingsModels />
</Tabs.Content> </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> </Tabs>
</Dialog> </Dialog>
) )

View File

@@ -15,6 +15,7 @@ import {
Switch, Switch,
untrack, untrack,
type ComponentProps, type ComponentProps,
type JSXElement,
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) 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: { export default function FileTree(props: {
path: string path: string
class?: string class?: string
@@ -230,178 +414,13 @@ export default function FileTree(props: {
return out 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 ( return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1 const deep = () => deeps().get(node.path) ?? -1
const Wrapper = (p: ParentProps) => { const kind = () => visibleKind(node, kinds(), marks())
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>
)
}
return ( return (
<Switch> <Switch>
@@ -415,13 +434,21 @@ export default function FileTree(props: {
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
> >
<Collapsible.Trigger> <Collapsible.Trigger>
<Wrapper> <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<Node node={node}> <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"> <div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" /> <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div> </div>
</Node> </FileTreeNode>
</Wrapper> </FileTreeNodeTooltip>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content class="relative pt-0.5"> <Collapsible.Content class="relative pt-0.5">
<div <div
@@ -451,12 +478,23 @@ export default function FileTree(props: {
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={node.type === "file"}> <Match when={node.type === "file"}>
<Wrapper> <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}> <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" /> <div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" /> <FileIcon node={node} class="text-icon-weak size-4" />
</Node> </FileTreeNode>
</Wrapper> </FileTreeNodeTooltip>
</Match> </Match>
</Switch> </Switch>
) )

View File

@@ -1,17 +1,26 @@
import { ComponentProps, splitProps } from "solid-js" import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
export interface LinkProps extends ComponentProps<"button"> { export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string href: string
} }
export function Link(props: LinkProps) { export function Link(props: LinkProps) {
const platform = usePlatform() const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children"]) const [local, rest] = splitProps(props, ["href", "children", "class"])
return ( 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} {local.children}
</button> </a>
) )
} }

View File

@@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isFocused = createFocusSignal(() => editorRef) 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(() => { createEffect(() => {
params.id params.id
if (params.id) return if (params.id) return
@@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => { createEffect(() => {
if (!isFocused()) setStore("popover", null) if (!isFocused()) closePopover()
}) })
// Safety: reset composing state on focus change to prevent stuck state // 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) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
setStore("popover", null) closePopover()
if (cmd.type === "custom") { if (cmd.type === "custom") {
const text = `/${cmd.trigger} ` const text = `/${cmd.trigger} `
editorRef.innerHTML = "" setEditorText(text)
editorRef.textContent = text
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
requestAnimationFrame(() => { focusEditorEnd()
editorRef.focus()
const range = document.createRange()
const sel = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
sel?.removeAllRanges()
sel?.addRange(range)
})
return return
} }
editorRef.innerHTML = "" clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash") command.trigger(cmd.id, "slash")
} }
@@ -454,7 +486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const renderEditor = (parts: Prompt) => { const renderEditor = (parts: Prompt) => {
editorRef.innerHTML = "" clearEditor()
for (const part of parts) { for (const part of parts) {
if (part.type === "text") { if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content)) editorRef.appendChild(createTextFragment(part.content))
@@ -514,34 +546,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mirror.input = false mirror.input = false
if (isNormalizedEditor()) return if (isNormalizedEditor()) return
const selection = window.getSelection() renderEditorWithCursor(inputParts)
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)
}
return return
} }
const domParts = parseFromDOM() const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection() renderEditorWithCursor(inputParts)
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)
}
}, },
), ),
) )
@@ -636,11 +648,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) { if (shouldReset) {
setStore("popover", null) closePopover()
if (store.historyIndex >= 0 && !store.applyingHistory) { resetHistoryNavigation()
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
if (prompt.dirty()) { if (prompt.dirty()) {
mirror.input = true mirror.input = true
prompt.set(DEFAULT_PROMPT, 0) prompt.set(DEFAULT_PROMPT, 0)
@@ -662,16 +671,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashOnInput(slashMatch[1]) slashOnInput(slashMatch[1])
setStore("popover", "slash") setStore("popover", "slash")
} else { } else {
setStore("popover", null) closePopover()
} }
} else { } else {
setStore("popover", null) closePopover()
} }
if (store.historyIndex >= 0 && !store.applyingHistory) { resetHistoryNavigation()
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
mirror.input = true mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition) prompt.set([...rawParts, ...images], cursorPosition)
@@ -732,7 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
handleInput() handleInput()
setStore("popover", null) closePopover()
} }
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -782,8 +788,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptLength, promptLength,
addToHistory, addToHistory,
resetHistoryNavigation: () => { resetHistoryNavigation: () => {
setStore("historyIndex", -1) resetHistoryNavigation(true)
setStore("savedPrompt", null)
}, },
setMode: (mode) => setStore("mode", mode), setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover), setPopover: (popover) => setStore("popover", popover),
@@ -872,7 +877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (ctrl && event.code === "KeyG") { if (ctrl && event.code === "KeyG") {
if (store.popover) { if (store.popover) {
setStore("popover", null) closePopover()
event.preventDefault() event.preventDefault()
return return
} }
@@ -923,7 +928,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (store.popover) { if (store.popover) {
setStore("popover", null) closePopover()
} else if (working()) { } else if (working()) {
abort() abort()
} }

View File

@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
<Show when={props.items.length > 0}> <Show when={props.items.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.items}> <For each={props.items}>
{(item) => ( {(item) => {
<Tooltip const directory = getDirectory(item.path)
value={ const filename = getFilename(item.path)
<span class="flex max-w-[300px]"> const label = getFilenameTruncated(item.path, 14)
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> const selected = props.active(item)
{getDirectory(item.path)}
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>
<span class="shrink-0">{getFilename(item.path)}</span> }
</span> placement="top"
} openDelay={2000}
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
props.active(item),
"bg-background-stronger": !props.active(item),
}}
onClick={() => props.openComment(item)}
> >
<div class="flex items-center gap-1.5"> <div
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> classList={{
<div class="flex items-center text-11-regular min-w-0 font-medium"> "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,
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
<Show when={item.selection}> "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
{(sel) => ( selected,
<span class="text-text-weak whitespace-nowrap shrink-0"> "bg-background-stronger": !selected,
{sel().startLine === sel().endLine }}
? `:${sel().startLine}` onClick={() => props.openComment(item)}
: `:${sel().startLine}-${sel().endLine}`} >
</span> <div class="flex items-center gap-1.5">
)} <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
</Show> <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> </div>
<IconButton <Show when={item.comment}>
type="button" {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
icon="close-small" </Show>
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> </div>
<Show when={item.comment}> </Tooltip>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} )
</Show> }}
</div>
</Tooltip>
)}
</For> </For>
</div> </div>
</Show> </Show>

View File

@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
label: string label: string
} }
const kindToIcon = {
image: "photo",
"@mention": "link",
} as const
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => { export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
return ( return (
<Show when={props.type !== null}> <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="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"> <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> <span class="text-14-regular">{props.label}</span>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
removeLabel: string 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) => { export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return ( return (
<Show when={props.attachments.length > 0}> <Show when={props.attachments.length > 0}>
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<Show <Show
when={attachment.mime.startsWith("image/")} when={attachment.mime.startsWith("image/")}
fallback={ 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" /> <Icon name="folder" class="size-6 text-text-weak" />
</div> </div>
} }
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<img <img
src={attachment.dataUrl} src={attachment.dataUrl}
alt={attachment.filename} 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)} onClick={() => props.onOpen(attachment)}
/> />
</Show> </Show>
<button <button
type="button" type="button"
onClick={() => props.onRemove(attachment.id)} 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} aria-label={props.removeLabel}
> >
<Icon name="close" class="size-3 text-text-weak" /> <Icon name="close" class="size-3 text-text-weak" />
</button> </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> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div> </div>
</div> </div>

View File

@@ -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>} fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => ( {(item) => {
<button const active = props.atActive === props.atKey(item)
classList={{ const shared = {
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, "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), "bg-surface-raised-base-hover": active,
}} }
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))} if (item.type === "agent") {
> return (
<Show <button
when={item.type === "agent"} classList={shared}
fallback={ onClick={() => props.onAtSelect(item)}
<> onMouseEnter={() => props.setAtActive(props.atKey(item))}
<FileIcon >
node={{ path: item.type === "file" ? item.path : "", type: "file" }} <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
class="shrink-0 size-4" <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
/> </button>
<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("/") const isDirectory = item.path.endsWith("/")
? item.path const directory = isDirectory ? item.path : getDirectory(item.path)
: getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path)
: ""}
</span> return (
<Show when={item.type === "file" && !item.path.endsWith("/")}> <button
<span class="text-text-strong whitespace-nowrap"> classList={shared}
{item.type === "file" ? getFilename(item.path) : ""} onClick={() => props.onAtSelect(item)}
</span> onMouseEnter={() => props.setAtActive(props.atKey(item))}
</Show>
</div>
</>
}
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<span class="text-14-regular text-text-strong whitespace-nowrap"> <div class="flex items-center text-14-regular min-w-0">
@{item.type === "agent" ? item.name : ""} <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
</span> <Show when={!isDirectory}>
</Show> <span class="text-text-strong whitespace-nowrap">{filename}</span>
</button> </Show>
)} </div>
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>

View File

@@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" 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) => { export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const reply = (answers: QuestionAnswer[]) => { const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return if (store.sending) return
setStore("sending", true) setStore("sending", true)
sdk.client.question try {
.reply({ requestID: props.request.id, answers }) await sdk.client.question.reply({ requestID: props.request.id, answers })
.catch(fail) } catch (err) {
.finally(() => setStore("sending", false)) fail(err)
} finally {
setStore("sending", false)
}
} }
const reject = () => { const reject = async () => {
if (store.sending) return if (store.sending) return
setStore("sending", true) setStore("sending", true)
sdk.client.question try {
.reject({ requestID: props.request.id }) await sdk.client.question.reject({ requestID: props.request.id })
.catch(fail) } catch (err) {
.finally(() => setStore("sending", false)) fail(err)
} finally {
setStore("sending", false)
}
} }
const submit = () => { 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 pick = (answer: string, custom: boolean = false) => {
const answers = [...store.answers] setStore("answers", pickAnswer(store.answers, store.tab, answer))
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) { if (custom) {
const inputs = [...store.custom] setStore("custom", writeCustom(store.custom, store.tab, answer))
inputs[store.tab] = answer
setStore("custom", inputs)
} }
if (single()) { if (single()) {
reply([[answer]]) void reply([[answer]])
return return
} }
@@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
const toggle = (answer: string) => { const toggle = (answer: string) => {
const existing = store.answers[store.tab] ?? [] setStore("answers", toggleAnswer(store.answers, store.tab, answer))
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)
} }
const selectTab = (index: number) => { const selectTab = (index: number) => {
@@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
if (multi()) { if (multi()) {
const existing = store.answers[store.tab] ?? [] setStore("answers", appendAnswer(store.answers, store.tab, value))
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false) setStore("editing", false)
return return
} }
@@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
value={input()} value={input()}
disabled={store.sending} disabled={store.sending}
onInput={(e) => { onInput={(e) => {
const inputs = [...store.custom] setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value))
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}} }}
/> />
<Button type="submit" variant="primary" size="small" disabled={store.sending}> <Button type="submit" variant="primary" size="small" disabled={store.sending}>

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" 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 { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
} }
createEffect(() => { createEffect(() => {
name()
props.url props.url
props.status?.version props.status?.version
if (typeof requestAnimationFrame === "function") { queueMicrotask(check)
requestAnimationFrame(check)
return
}
check()
}) })
onMount(() => { onMount(() => {
check() check()
if (typeof window === "undefined") return if (typeof ResizeObserver !== "function") return
window.addEventListener("resize", check) const observer = new ResizeObserver(check)
onCleanup(() => window.removeEventListener("resize", check)) if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
}) })
const tooltipValue = () => ( const tooltipValue = () => (
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span>{serverDisplayName(props.url)}</span> <span>{name()}</span>
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span> <span class="text-text-invert-base">{props.status?.version}</span>
</Show> </Show>
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
}} }}
/> />
<span ref={nameRef} class={props.nameClass ?? "truncate"}> <span ref={nameRef} class={props.nameClass ?? "truncate"}>
{serverDisplayName(props.url)} {name()}
</span> </span>
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>

View File

@@ -13,6 +13,18 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator" 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) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const params = useParams() const params = useParams()
@@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (!view().reviewPanel.opened()) view().reviewPanel.open() openSessionContext({
layout.fileTree.open() view: view(),
layout.fileTree.setTab("all") layout,
tabs().open("context") tabs: tabs(),
tabs().setActive("context") })
} }
const circle = () => ( const circle = () => (

View File

@@ -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()
})
})

View File

@@ -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)
}

View File

@@ -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)
},
}
}

View File

@@ -1,7 +1,6 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" 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 type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps { interface SessionContextTabProps {
messages: () => Message[] messages: () => Message[]
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> 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) { export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(metrics().totalCost)
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
return trimmed 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 providerLabel = createMemo(() => {
const c = ctx() const c = ctx()
if (!c) return "—" if (!c) return "—"
@@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) {
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c) return [] if (!c?.input) return []
const input = c.input return estimateSessionContextBreakdown({
if (!input) return [] messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
const out = { input: c.input,
system: systemPrompt()?.length ?? 0, systemPrompt: systemPrompt(),
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) })
}, },
), ),
) )
function Stat(statProps: { label: string; value: JSX.Element }) { const breakdownLabel = (key: SessionContextBreakdownKey) => {
return ( if (key === "system") return language.t("context.breakdown.system")
<div class="flex flex-col gap-1"> if (key === "user") return language.t("context.breakdown.user")
<div class="text-12-regular text-text-weak">{statProps.label}</div> if (key === "assistant") return language.t("context.breakdown.assistant")
<div class="text-12-medium text-text-strong">{statProps.value}</div> if (key === "tool") return language.t("context.breakdown.tool")
</div> return language.t("context.breakdown.other")
)
} }
const stats = createMemo(() => { 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.messages"), value: count.all.toLocaleString(language.locale()) },
{ label: language.t("context.stats.provider"), value: providerLabel() }, { label: language.t("context.stats.provider"), value: providerLabel() },
{ label: language.t("context.stats.model"), value: modelLabel() }, { label: language.t("context.stats.model"), value: modelLabel() },
{ label: language.t("context.stats.limit"), value: number(c?.limit) }, { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) },
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) }, { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) },
{ label: language.t("context.stats.usage"), value: percent(c?.usage) }, { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) },
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) }, { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) },
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) }, { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) },
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) }, { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) },
{ {
label: language.t("context.stats.cacheTokens"), 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()) }, { 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()), value: count.assistant.toLocaleString(language.locale()),
}, },
{ label: language.t("context.stats.totalCost"), value: cost() }, { 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.sessionCreated"), value: formatter().time(props.info()?.time.created) },
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) }, { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[] ] 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 scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => { const restoreScroll = () => {
const el = scroll const el = scroll
@@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
class="h-full" class="h-full"
style={{ style={{
width: `${segment.width}%`, 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()}> <For each={breakdown()}>
{(segment) => ( {(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak"> <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 class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{segment.label}</div> <div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent}</div> <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div> </div>
)} )}
</For> </For>
@@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple> <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> </Accordion>
</div> </div>
</div> </div>

View File

@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover" 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() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const layout = useLayout() const layout = useLayout()
@@ -53,62 +211,7 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession()) const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform))
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 [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true }) const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
@@ -154,10 +257,6 @@ export function SessionHeader() {
] as const ] 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(() => { const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true if (!platform.checkAppExists) return true
@@ -186,13 +285,7 @@ export function SessionHeader() {
const item = options().find((o) => o.id === app) const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
} }
const copyPath = () => { const copyPath = () => {
@@ -208,86 +301,15 @@ export function SessionHeader() {
description: directory, description: directory,
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => showRequestError(language, err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
} }
const [state, setState] = createStore({ const share = useSessionShare({
share: false, globalSDK,
unshare: false, currentSession,
copied: false, projectDirectory,
timer: undefined as number | undefined, platform,
}) })
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)
})
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 centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) 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"> <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> </div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator> <DropdownMenu.ItemIndicator>
@@ -428,7 +450,7 @@ export function SessionHeader() {
<Popover <Popover
title={language.t("session.share.popover.title")} title={language.t("session.share.popover.title")}
description={ description={
shareUrl() share.shareUrl()
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared") : language.t("session.share.popover.description.unshared")
} }
@@ -441,24 +463,24 @@ export function SessionHeader() {
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", "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 }, style: { scale: 1 },
}} }}
trigger={language.t("session.share.action.share")} trigger={language.t("session.share.action.share")}
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show <Show
when={shareUrl()} when={share.shareUrl()}
fallback={ fallback={
<div class="flex"> <div class="flex">
<Button <Button
size="large" size="large"
variant="primary" variant="primary"
class="w-1/2" class="w-1/2"
onClick={shareSession} onClick={share.shareSession}
disabled={state.share} disabled={share.state.share}
> >
{state.share {share.state.share
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -467,7 +489,7 @@ export function SessionHeader() {
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<TextField <TextField
value={shareUrl() ?? ""} value={share.shareUrl() ?? ""}
readOnly readOnly
copyable copyable
copyKind="link" copyKind="link"
@@ -479,10 +501,10 @@ export function SessionHeader() {
size="large" size="large"
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession} onClick={share.unshareSession}
disabled={state.unshare} disabled={share.state.unshare}
> >
{state.unshare {share.state.unshare
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -490,8 +512,8 @@ export function SessionHeader() {
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={share.viewShare}
disabled={state.unshare} disabled={share.state.unshare}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -500,10 +522,10 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Popover> </Popover>
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}> <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip <Tooltip
value={ value={
state.copied share.state.copied
? language.t("session.share.copy.copied") ? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink") : language.t("session.share.copy.copyLink")
} }
@@ -511,13 +533,13 @@ export function SessionHeader() {
gutter={8} gutter={8}
> >
<IconButton <IconButton
icon={state.copied ? "check" : "link"} icon={share.state.copied ? "check" : "link"}
variant="ghost" variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={copyLink} onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={state.unshare} disabled={share.state.unshare}
aria-label={ aria-label={
state.copied share.state.copied
? language.t("session.share.copy.copied") ? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink") : language.t("session.share.copy.copyLink")
} }

View File

@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main" const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create" 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 { interface NewSessionViewProps {
worktree: string worktree: string
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
} }
return ( 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="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" /> <Icon name="folder" size="small" />

View File

@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const command = useCommand() const command = useCommand()
const sortable = createSortable(props.tab) const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
})
return ( return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full"> <div class="relative h-full">
<Tabs.Trigger <Tabs.Trigger
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
hideCloseButton hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)} onMiddleClick={() => props.onTabClose(props.tab)}
> >
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show> <Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger> </Tabs.Trigger>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import type { JSX } from "solid-js" 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 { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd" import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 }, menuPosition: { x: 0, y: 0 },
blurEnabled: false, blurEnabled: false,
}) })
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("blurEnabled", false) setStore("blurEnabled", false)
setStore("title", props.terminal.title) setStore("title", props.terminal.title)
setStore("editing", true) 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 = () => { const save = () => {
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("menuOpen", true) 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 ( return (
<div <div
// @ts-ignore
use:sortable use:sortable
class="outline-none focus:outline-none focus-visible:outline-none" class="outline-none focus:outline-none focus-visible:outline-none"
classList={{ classList={{
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
<Show when={store.editing}> <Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto"> <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input <input
id={`terminal-title-input-${props.terminal.id}`} ref={input}
type="text" type="text"
value={store.title} value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)} onInput={(e) => setStore("title", e.currentTarget.value)}

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => { export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => { export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (

View File

@@ -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 { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS] 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 ( 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="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)]"> <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>
<div class="flex flex-col gap-8 w-full"> <div class="flex flex-col gap-8 w-full">
{/* Appearance Section */} <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"> <NotificationsSection />
<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 <SoundsSection />
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>
{/* 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>
{/* 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>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => { {(_) => {
@@ -395,53 +431,7 @@ export const SettingsGeneral: Component = () => {
}} }}
</Show> </Show>
{/* Updates Section */} <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>
<Show when={linux()}> <Show when={linux()}>
{(_) => { {(_) => {

View File

@@ -21,6 +21,9 @@ type KeybindMeta = {
group: KeybindGroup group: KeybindGroup
} }
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
type GroupKey = type GroupKey =
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
return sigs 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 = () => { export const SettingsKeybinds: Component = () => {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
command.keybinds(false) command.keybinds(false)
} }
const hasOverrides = createMemo(() => { const map = createMemo(() => keybinds(settings.current.keybinds))
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
if (!keybinds) return false const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
return Object.values(keybinds).some((x) => typeof x === "string")
})
const resetAll = () => { const resetAll = () => {
stop() stop()
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
const list = createMemo(() => { const list = createMemo(() => {
language.locale() language.locale()
const out = new Map<string, KeybindMeta>() return listFor(command, map(), language.t("command.palette"))
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
}) })
const title = (id: string) => list().get(id)?.title ?? "" const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => { const grouped = createMemo(() => groupedFor(list()))
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 filtered = createMemo(() => { const filtered = createMemo(() => {
const query = store.filter.toLowerCase().trim() return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
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
}) })
const hasResults = createMemo(() => { const hasResults = createMemo(() => {
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
return map return map
}) })
const setKeybind = (id: string, keybind: string) => { const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
settings.keybinds.set(id, keybind)
}
onMount(() => { useKeyCapture({
const handle = (event: KeyboardEvent) => { active: () => store.active,
const id = store.active stop,
if (!id) return set: setKeybind,
used,
event.preventDefault() language,
event.stopPropagation()
event.stopImmediatePropagation()
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)
})
}) })
onCleanup(() => { onCleanup(() => {

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => { export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (

View File

@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] 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 = () => { export const SettingsModels: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
<Show <Show
when={!list.grouped.loading} when={!list.grouped.loading}
fallback={ fallback={
<div class="flex flex-col items-center justify-center py-12 text-center"> <ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
<span class="text-14-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</span>
</div>
} }
> >
<Show <Show
when={list.flat().length > 0} when={list.flat().length > 0}
fallback={ fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
<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>
}
> >
<For each={list.grouped.latest}> <For each={list.grouped.latest}>
{(group) => ( {(group) => (

View File

@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
const nextValue = const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
globalSync.set("config", "permission", { ...map, [id]: nextValue }) const rollback = (err: unknown) => {
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
globalSync.set("config", "permission", before) globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message }) 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 ( return (

View File

@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
type ProviderSource = "env" | "api" | "config" | "custom" 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 = () => { export const SettingsProviders: Component = () => {
const dialog = useDialog() const dialog = useDialog()
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
return items 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) const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment") if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey") if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") { if (current === "config") {
const id = (item as { id?: string }).id if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config") return language.t("settings.providers.tag.config")
} }
if (current === "custom") return language.t("settings.providers.tag.custom") if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other") 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 isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID] const provider = globalSync.data.config.provider?.[providerID]
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
</div> </div>
<Show when={item.id === "opencode"}> <Show when={note(item.id)}>
<span class="text-12-regular text-text-weak pl-8"> {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
{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> </Show>
</div> </div>
<Button <Button

View File

@@ -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 { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,16 +7,151 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server" import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
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 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 useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
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))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
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(() => {
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
})
})
return { url, refresh: () => setTick((value) => value + 1) }
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
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: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setLoading(null)
}
}
return { loading, toggle }
}
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
@@ -26,115 +161,35 @@ export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
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 fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.url const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((item) => item !== current)]
}) })
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => { const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const list = servers() const mcp = useMcpToggle({ sync, sdk, language })
if (!list.length) return list const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
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
}
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)
})
})
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))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() => const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status })) .map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (store.loading) return
setStore("loading", 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)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setStore("loading", null)
}
}
const lspItems = createMemo(() => sync.data.lsp ?? []) const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? []) const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => { const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true 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 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 ( return (
<Popover <Popover
triggerAs={Button} 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.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"> <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")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <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"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(url) => { {(url) => {
const isActive = () => url === server.url const isBlocked = () => health[url]?.healthy === false
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -217,13 +268,13 @@ export function StatusPopover() {
> >
<ServerRow <ServerRow
url={url} url={url}
status={status()} status={health[url]}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ 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"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -231,7 +282,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={isActive()}> <Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>
@@ -243,7 +294,7 @@ export function StatusPopover() {
<Button <Button
variant="secondary" variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5" 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")} {language.t("status.popover.action.manageServers")}
</Button> </Button>
@@ -269,8 +320,8 @@ export function StatusPopover() {
<button <button
type="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" 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)} onClick={() => mcp.toggle(item.name)}
disabled={store.loading === item.name} disabled={mcp.loading() === item.name}
> >
<div <div
classList={{ classList={{
@@ -286,8 +337,8 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={store.loading === item.name} disabled={mcp.loading() === item.name}
onChange={() => toggleMcp(item.name)} onChange={() => mcp.toggle(item.name)}
/> />
</div> </div>
</button> </button>
@@ -334,23 +385,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show <Show
when={plugins().length > 0} when={plugins().length > 0}
fallback={ fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
<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>
}
> >
<For each={plugins()}> <For each={plugins()}>
{(plugin) => ( {(plugin) => (

View File

@@ -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) => { export const Terminal = (props: TerminalProps) => {
const platform = usePlatform() const platform = usePlatform()
const sdk = useSDK() const sdk = useSDK()
@@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let disposed = false let disposed = false
const cleanups: VoidFunction[] = [] const cleanups: VoidFunction[] = []
const start = const start =
@@ -84,12 +167,23 @@ export const Terminal = (props: TerminalProps) => {
for (const fn of fns) { for (const fn of fns) {
try { try {
fn() fn()
} catch { } catch (err) {
// ignore 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 getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light" const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode] const fallback = DEFAULT_TERMINAL_COLORS[mode]
@@ -219,27 +313,6 @@ export const Terminal = (props: TerminalProps) => {
ghostty = g ghostty = g
term = t 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) => { t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
@@ -255,12 +328,6 @@ export const Terminal = (props: TerminalProps) => {
return matchKeybind(keybinds, event) 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 fit = new mod.FitAddon()
const serializer = new SerializeAddon() const serializer = new SerializeAddon()
cleanups.push(() => disposeIfDisposable(fit)) cleanups.push(() => disposeIfDisposable(fit))
@@ -270,24 +337,7 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
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))
focusTerminal() focusTerminal()
@@ -316,15 +366,7 @@ export const Terminal = (props: TerminalProps) => {
const onResize = t.onResize(async (size) => { const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty await pushSize(size.cols, size.rows)
.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
.catch(() => {})
} }
}) })
cleanups.push(() => disposeIfDisposable(onResize)) cleanups.push(() => disposeIfDisposable(onResize))
@@ -346,15 +388,7 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
sdk.client.pty void pushSize(t.cols, t.rows)
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
} }
socket.addEventListener("open", handleOpen) socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("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) { if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next cursor = next
} }
} catch { } catch (err) {
// ignore debugTerminal("invalid websocket control frame", err)
} }
return return
} }
@@ -425,25 +459,7 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => { onCleanup(() => {
disposed = true disposed = true
const t = term persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
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(),
})
}
cleanup() cleanup()
}) })

View File

@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { applyPath, backPath, forwardPath } from "./titlebar-history" 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() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -82,22 +104,7 @@ export function Titlebar() {
const getWin = () => { const getWin = () => {
if (platform.platform !== "desktop") return if (platform.platform !== "desktop") return
return currentDesktopWindow()
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()
} }
createEffect(() => { createEffect(() => {
@@ -106,13 +113,8 @@ export function Titlebar() {
const scheme = theme.colorScheme() const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) const win = currentThemeWindow()
.__TAURI__ if (!win?.setTheme) return
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined) void win.setTheme(value).catch(() => undefined)
}) })

View File

@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
function actionId(id: string) { function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id 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) 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 type KeybindConfig = string
export interface Keybind { export interface Keybind {
@@ -56,6 +62,8 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void onHighlight?: () => (() => void) | void
} }
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = { export type CommandCatalogItem = {
title: string title: string
description?: string description?: string
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+") 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({ export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command", name: "Command",
init: () => { init: () => {
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map return map
}) })
const run = (id: string, source?: "palette" | "keybind" | "slash") => { const optionMap = createMemo(() => {
const map = new Map<string, CommandOption>()
for (const option of options()) { for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) { map.set(option.id, option)
option.onSelect?.(source) map.set(actionId(option.id), option)
return
}
} }
return map
})
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
option?.onSelect?.(source)
} }
const showPalette = () => { const showPalette = () => {
@@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (suspended() || dialog.active) return if (suspended() || dialog.active) return
const sig = signatureFromEvent(event) const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
if (palette().has(sig)) { if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
if (isPalette) {
event.preventDefault() event.preventDefault()
showPalette() showPalette()
return return
} }
const option = keymap().get(sig)
if (!option) return if (!option) return
event.preventDefault() event.preventDefault()
option.onSelect?.("keybind") option.onSelect?.("keybind")
@@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return { return {
register, register,
trigger(id: string, source?: "palette" | "keybind" | "slash") { trigger(id: string, source?: CommandSource) {
run(id, source) run(id, source)
}, },
keybind(id: string) { keybind(id: string) {
@@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}, },
show: showPalette, show: showPalette,
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setStore("suspendCount", (count) => count + (enabled ? -1 : 1)) setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
}, },
suspended, suspended,
get catalog() { get catalog() {

View File

@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
dispose() 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()
})
})
}) })

View File

@@ -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 { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -20,6 +20,19 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20 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 = { type CommentStore = {
comments: Record<string, LineComment[]> comments: Record<string, LineComment[]>
} }
@@ -31,24 +44,24 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time) .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>) { function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({ const [state, setState] = createStore({
focus: null as CommentFocus | null, focus: null as CommentFocus | null,
active: 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)) => 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)) => const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("active", value) setRef("active", value)
const list = (file: string) => store.comments[file] ?? [] const list = (file: string) => store.comments[file] ?? []
@@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
batch(() => { batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next]) setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id }) setFocus({ file: input.file, id: next.id })
}) })
@@ -71,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
const remove = (file: string, id: string) => { const remove = (file: string, id: string) => {
batch(() => { batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) 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?.file === file && current.id === id ? null : current))
setFocus((current) => (current?.id === id ? null : current))
}) })
} }
const clear = () => { const clear = () => {
batch(() => { batch(() => {
setStore("comments", reconcile({})) setStore("comments", reconcile({}))
setState("all", [])
setFocus(null) setFocus(null)
setActive(null) setActive(null)
}) })
@@ -87,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
return { return {
list, list,
all: () => state.all, all,
add, add,
remove, remove,
clear, clear,
focus: () => state.focus, focus: () => state.focus,
setFocus, setFocus,
clearFocus: () => setFocus(null), clearFocus: () => setRef("focus", null),
active: () => state.active, active: () => state.active,
setActive, setActive,
clearActive: () => setActive(null), clearActive: () => setRef("active", null),
reindex: () => setState("all", aggregate(store.comments)),
} }
} }
@@ -117,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
) )
const session = createCommentSessionState(store, setStore) const session = createCommentSessionState(store, setStore)
createEffect(() => {
if (!ready()) return
session.reindex()
})
return { return {
ready, ready,
list: session.list, list: session.list,
@@ -145,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const params = useParams() const params = useParams()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const split = key.lastIndexOf("\n") const decoded = decodeSessionKey(key)
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id), value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
dispose, dispose,
})) }))
}, },
@@ -162,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
onCleanup(() => cache.clear()) onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => { const load = (dir: string, id: string | undefined) => {
const key = `${dir}\n${id ?? WORKSPACE_KEY}` const key = sessionKey(dir, id)
return cache.get(key).value return cache.get(key).value
} }

View File

@@ -43,6 +43,12 @@ export {
touchFileContent, 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({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false, gate: false,
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore("file", file, { path: file, name: getFilename(file) }) 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 load = (input: string, options?: { force?: boolean }) => {
const file = path.normalize(input) const file = path.normalize(input)
if (!file) return Promise.resolve() if (!file) return Promise.resolve()
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const pending = inflight.get(key) const pending = inflight.get(key)
if (pending) return pending if (pending) return pending
setStore( setLoading(file)
"file",
file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const promise = sdk.client.file const promise = sdk.client.file
.read({ path: file }) .read({ path: file })
.then((x) => { .then((x) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = x.data const content = x.data
setStore( setLoaded(file, content)
"file",
file,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = content
}),
)
if (!content) return if (!content) return
touchFileContent(file, approxBytes(content)) touchFileContent(file, approxBytes(content))
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
.catch((e) => { .catch((e) => {
if (scope() !== directory) return if (scope() !== directory) return
setStore( setLoadError(file, errorMessage(e))
"file",
file,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: e.message,
})
}) })
.finally(() => { .finally(() => {
inflight.delete(key) inflight.delete(key)
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state return state
} }
const scrollTop = (input: string) => view().scrollTop(path.normalize(input)) function withPath(input: string, action: (file: string) => unknown) {
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input)) return action(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)
} }
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(() => { onCleanup(() => {
stop() stop()

View File

@@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}>() }>()
type Queued = { directory: string; payload: Event } type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
let queue: Array<Queued | undefined> = [] let queue: Queued[] = []
let buffer: Array<Queued | undefined> = [] let buffer: Queued[] = []
const coalesced = new Map<string, number>() const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
@@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
last = Date.now() last = Date.now()
batch(() => { batch(() => {
for (const event of events) { for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload) emitter.emit(event.directory, event.payload)
} }
}) })
@@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const schedule = () => { const schedule = () => {
if (timer) return if (timer) return
const elapsed = Date.now() - last 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 () => { void (async () => {
const events = await eventSdk.global.event() const events = await eventSdk.global.event()
let yielded = Date.now() let yielded = Date.now()
@@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (k) { if (k) {
const i = coalesced.get(k) const i = coalesced.get(k)
if (i !== undefined) { if (i !== undefined) {
queue[i] = undefined queue[i] = { directory, payload }
continue
} }
coalesced.set(k, queue.length) coalesced.set(k, queue.length)
} }
queue.push({ directory, payload }) queue.push({ directory, payload })
schedule() schedule()
if (Date.now() - yielded < 8) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0)) await new Promise<void>((resolve) => setTimeout(resolve, 0))
} }
})() })()
.finally(flush) .finally(flush)
.catch(() => undefined) .catch((error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream failed", error)
})
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()

View File

@@ -47,6 +47,20 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" 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() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform() const platform = usePlatform()
@@ -81,19 +95,11 @@ function createGlobalSync() {
const updateStats = (activeDirectoryStores: number) => { const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return if (!import.meta.env.DEV) return
;( setDevStats({
globalThis as {
__OPENCODE_GLOBAL_SYNC_STATS?: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}
}
).__OPENCODE_GLOBAL_SYNC_STATS = {
activeDirectoryStores, activeDirectoryStores,
evictions: stats.evictions, evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback, loadSessionsFullFetchFallback: stats.loadSessionsFallback,
} })
} }
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -204,7 +210,10 @@ function createGlobalSync() {
.catch((err) => { .catch((err) => {
console.error("Failed to load sessions", err) console.error("Failed to load sessions", err)
const project = getFilename(directory) 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) sessionLoads.set(directory, promise)
@@ -307,12 +316,28 @@ function createGlobalSync() {
void bootstrap() void bootstrap()
}) })
function projectMeta(directory: string, patch: ProjectMeta) { const projectApi = {
children.projectMeta(directory, patch) 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) { const updateConfig = async (config: Config) => {
children.projectIcon(directory, value) setGlobalStore("reload", "pending")
return globalSDK.client.global.config
.update({ config })
.then(bootstrap)
.then(() => {
setGlobalStore("reload", "complete")
})
.catch((error) => {
setGlobalStore("reload", undefined)
throw error
})
} }
return { return {
@@ -326,19 +351,8 @@ function createGlobalSync() {
}, },
child: children.child, child: children.child,
bootstrap, bootstrap,
updateConfig: (config: Config) => { updateConfig,
setGlobalStore("reload", "pending") project: projectApi,
return globalSDK.client.global.config.update({ config }).finally(() => {
setTimeout(() => {
setGlobalStore("reload", "complete")
}, 1000)
})
},
project: {
loadSessions,
meta: projectMeta,
icon: projectIcon,
},
} }
} }

View File

@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
const highlights = releases.slice(start, end).flatMap((release) => release.highlights) const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
const seen = new Set<string>() const seen = new Set<string>()
const unique = highlights.filter((highlight) => { const unique = highlights.filter((highlight) => {
const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join( const key = dedupeKey(highlight)
"\n",
)
if (seen.has(key)) return false if (seen.has(key)) return false
seen.add(key) seen.add(key)
return true return true
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
return unique.slice(0, 5) 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({ export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
name: "Highlights", name: "Highlights",
gate: false, gate: false,
@@ -140,14 +148,57 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const [from, setFrom] = createSignal<string | undefined>(undefined) const [from, setFrom] = createSignal<string | undefined>(undefined)
const [to, setTo] = createSignal<string | undefined>(undefined) const [to, setTo] = createSignal<string | undefined>(undefined)
const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
const state = { started: false } const state = { started: false }
let timer: ReturnType<typeof setTimeout> | undefined
const clearTimer = () => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
}
const markSeen = () => { const markSeen = () => {
if (!platform.version) return if (!platform.version) return
setStore("version", platform.version) setStore("version", platform.version)
} }
const start = (previous: string) => {
if (!settings.general.releaseNotes()) {
markSeen()
return
}
const fetcher = platform.fetch ?? fetch
const controller = new AbortController()
onCleanup(() => {
controller.abort()
clearTimer()
})
fetcher(CHANGELOG_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
.then((json) => {
if (!json) return
const highlights = loadReleaseHighlights(json, platform.version, previous)
if (controller.signal.aborted) return
if (highlights.length === 0) {
markSeen()
return
}
timer = setTimeout(() => {
timer = undefined
markSeen()
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
}, 500)
})
.catch(() => undefined)
}
createEffect(() => { createEffect(() => {
if (state.started) return if (state.started) return
if (!ready()) return if (!ready()) return
@@ -165,51 +216,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
setFrom(previous) setFrom(previous)
setTo(platform.version) setTo(platform.version)
start(previous)
if (!settings.general.releaseNotes()) {
markSeen()
return
}
const fetcher = platform.fetch ?? fetch
const controller = new AbortController()
onCleanup(() => {
controller.abort()
const id = timer()
if (id === undefined) return
clearTimeout(id)
})
fetcher(CHANGELOG_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.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,
})
if (controller.signal.aborted) return
if (highlights.length === 0) {
markSeen()
return
}
const timer = setTimeout(() => {
markSeen()
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
}, 500)
setTimer(timer)
})
.catch(() => undefined)
}) })
return { return {

View File

@@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [
"th", "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" type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = { const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh, zh,
@@ -102,28 +162,9 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language] const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) { for (const language of languages) {
if (!language) continue if (!language) continue
if (language.toLowerCase().startsWith("zh")) { const normalized = language.toLowerCase()
if (language.toLowerCase().includes("hant")) return "zht" const match = localeMatchers.find((entry) => entry.match(normalized))
return "zh" if (match) return match.locale
}
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"
} }
return "en" return "en"
@@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}), }),
) )
const locale = createMemo<Locale>(() => { const locale = createMemo<Locale>(() =>
if (store.locale === "zh") return "zh" LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
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"
})
createEffect(() => { createEffect(() => {
const current = locale() const current = locale()
@@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
setStore("locale", current) setStore("locale", current)
}) })
const base = i18n.flatten({ ...en, ...uiEn }) const dict = createMemo<Dictionary>(() => DICT[locale()])
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 t = i18n.translator(dict, i18n.resolveTemplate) const t = i18n.translator(dict, i18n.resolveTemplate)
const labelKey: Record<Locale, keyof Dictionary> = { const label = (value: Locale) => t(LABEL_KEY[value])
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])
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return

View File

@@ -11,6 +11,9 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const 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 type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) { export function getAvatarColors(key?: string) {
@@ -85,6 +88,14 @@ export function pruneSessionKeys(input: {
.slice(input.max) .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({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { init: () => {
@@ -116,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") 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 { return {
...fileTree, ...fileTree,
opened: true, opened: true,
width: width === 260 ? 344 : width, width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
tab: "changes", tab: "changes",
} }
})() })()
@@ -151,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
width: 344, width: DEFAULT_PANEL_WIDTH,
workspaces: {} as Record<string, boolean>, workspaces: {} as Record<string, boolean>,
workspacesDefault: false, workspacesDefault: false,
}, },
terminal: { terminal: {
height: 280, height: DEFAULT_TERMINAL_HEIGHT,
opened: false, opened: false,
}, },
review: { review: {
@@ -165,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
fileTree: { fileTree: {
opened: true, opened: true,
width: 344, width: DEFAULT_PANEL_WIDTH,
tab: "changes" as "changes" | "all", tab: "changes" as "changes" | "all",
}, },
session: { session: {
width: 600, width: DEFAULT_SESSION_WIDTH,
}, },
mobileSidebar: { mobileSidebar: {
opened: false, opened: false,
@@ -184,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const MAX_SESSION_KEYS = 50 const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false } const usage = {
const used = new Map<string, number>() active: undefined as string | undefined,
pruned: false,
used: new Map<string, number>(),
}
const SESSION_STATE_KEYS = [ const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" }, { key: "prompt", legacy: "prompt", version: "v2" },
@@ -214,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const drop = pruneSessionKeys({ const drop = pruneSessionKeys({
keep, keep,
max: MAX_SESSION_KEYS, max: MAX_SESSION_KEYS,
used, used: usage.used,
view: Object.keys(store.sessionView), view: Object.keys(store.sessionView),
tabs: Object.keys(store.sessionTabs), tabs: Object.keys(store.sessionTabs),
}) })
@@ -233,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
dropSessionState(drop) dropSessionState(drop)
for (const key of drop) { for (const key of drop) {
used.delete(key) usage.used.delete(key)
} }
} }
function touch(sessionKey: string) { function touch(sessionKey: string) {
meta.active = sessionKey usage.active = sessionKey
used.set(sessionKey, Date.now()) usage.used.set(sessionKey, Date.now())
if (!ready()) return if (!ready()) return
if (meta.pruned) return if (usage.pruned) return
meta.pruned = true usage.pruned = true
prune(sessionKey) prune(sessionKey)
} }
@@ -253,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => { onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey] const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey const keep = usage.active ?? sessionKey
if (!current) { if (!current) {
setStore("sessionView", sessionKey, { scroll: next }) setStore("sessionView", sessionKey, { scroll: next })
prune(keep) prune(keep)
@@ -269,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
if (meta.pruned) return if (usage.pruned) return
const active = meta.active const active = usage.active
if (!active) return if (!active) return
meta.pruned = true usage.pruned = true
prune(active) prune(active)
}) })
@@ -546,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
fileTree: { fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true), 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"), tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") { setTab(tab: "changes" | "all") {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
return return
} }
setStore("fileTree", "tab", tab) setStore("fileTree", "tab", tab)
}, },
open() { open() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab: "changes" }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", true) setStore("fileTree", "opened", true)
}, },
close() { close() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: false, width: 344, tab: "changes" }) setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", false) setStore("fileTree", "opened", false)
}, },
toggle() { toggle() {
if (!store.fileTree) { if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab: "changes" }) setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return return
} }
setStore("fileTree", "opened", (x) => !x) setStore("fileTree", "opened", (x) => !x)
@@ -585,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
session: { session: {
width: createMemo(() => store.session?.width ?? 600), width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
resize(width: number) { resize(width: number) {
if (!store.session) { if (!store.session) {
setStore("session", { width }) setStore("session", { width })
@@ -617,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
pendingMessage: messageID, pendingMessage: messageID,
pendingMessageAt: at, pendingMessageAt: at,
}) })
prune(meta.active ?? sessionKey) prune(usage.active ?? sessionKey)
return return
} }
@@ -658,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setTerminalOpened(next: boolean) { function setTerminalOpened(next: boolean) {
const current = store.terminal const current = store.terminal
if (!current) { if (!current) {
setStore("terminal", { height: 280, opened: next }) setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
return return
} }
@@ -755,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
async open(tab: string) { async open(tab: string) {
const session = key() const session = key()
const current = store.sessionTabs[session] ?? { all: [] } const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
setStore("sessionTabs", session, next)
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)
}, },
close(tab: string) { close(tab: string) {
const session = key() const session = key()

View File

@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const providers = useProviders() const providers = useProviders()
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
function isModelValid(model: ModelKey) { function isModelValid(model: ModelKey) {
const provider = providers.all().find((x) => x.id === model.providerID) const provider = providers.all().find((x) => x.id === model.providerID)
return ( return !!provider?.models[model.modelID] && connected().has(model.providerID)
!!provider?.models[model.modelID] &&
providers
.connected()
.map((p) => p.id)
.includes(model.providerID)
)
} }
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { 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 agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!value) return if (!value) return
setStore("current", value.name) setStore("current", value.name)
if (value.model) if (value.model)
model.set({ setModel({
providerID: value.model.providerID, providerID: value.model.providerID,
modelID: value.model.modelID, modelID: value.model.modelID,
}) })
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: {}, model: {},
}) })
const fallbackModel = createMemo<ModelKey | undefined>(() => { const resolveConfigured = () => {
if (sync.data.config.model) { if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/") const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) { const key = { providerID, modelID }
return { if (isModelValid(key)) return key
providerID, }
modelID,
}
}
}
const resolveRecent = () => {
for (const item of models.recent.list()) { for (const item of models.recent.list()) {
if (isModelValid(item)) { if (isModelValid(item)) return item
return item
}
} }
}
const resolveDefault = () => {
const defaults = providers.default() const defaults = providers.default()
for (const p of providers.connected()) { for (const provider of providers.connected()) {
const configured = defaults[p.id] const configured = defaults[provider.id]
if (configured) { if (configured) {
const key = { providerID: p.id, modelID: configured } const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key if (isModelValid(key)) return key
} }
const first = Object.values(p.models)[0] const first = Object.values(provider.models)[0]
if (!first) continue if (!first) continue
const key = { providerID: p.id, modelID: first.id } const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key if (isModelValid(key)) return key
} }
}
return undefined const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
}) })
const current = createMemo(() => { 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 { return {
ready: models.ready, ready: models.ready,
current, current,
recent, recent,
list: models.list, list: models.list,
cycle, cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) { set,
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)
})
},
visible(model: ModelKey) { visible(model: ModelKey) {
return models.visible(model) return models.visible(model)
}, },

View File

@@ -16,6 +16,12 @@ type Store = {
variant?: Record<string, string | undefined> 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({ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models", name: "Models",
init: () => { 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(() => const latest = createMemo(() =>
pipe( pipe(
available(), 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), groupBy((x) => x.provider.id),
mapValues((models) => mapValues((models) =>
pipe( 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 visibility = createMemo(() => {
const map = new Map<string, Visibility>() const map = new Map<string, Visibility>()
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
function update(model: ModelKey, state: Visibility) { function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) { if (index >= 0) {
setStore("user", index, { visibility: state }) setStore("user", index, (current) => ({ ...current, visibility: state }))
return return
} }
setStore("user", store.user.length, { ...model, visibility: state }) setStore("user", store.user.length, { ...model, visibility: state })
} }
const visible = (model: ModelKey) => { const visible = (model: ModelKey) => {
const key = `${model.providerID}:${model.modelID}` const key = modelKey(model)
const state = visibility().get(key) const state = visibility().get(key)
if (state === "hide") return false if (state === "hide") return false
if (state === "show") return true if (state === "show") return true
if (latestSet().has(key)) return true if (latestSet().has(key)) return true
const m = find(model) const date = release().get(key)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true if (!date?.isValid) return true
return false return false
} }
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
} }
const push = (model: ModelKey) => { const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
if (uniq.length > 5) uniq.pop() if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq) setStore("recent", uniq)
} }

View File

@@ -18,7 +18,7 @@ import { buildNotificationIndex } from "./notification-index"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
session?: string session?: string
metadata?: any metadata?: unknown
time: number time: number
viewed: boolean viewed: boolean
} }
@@ -84,89 +84,93 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const index = createMemo(() => buildNotificationIndex(store.list)) const index = createMemo(() => buildNotificationIndex(store.list))
const lookup = (directory: string, sessionID?: string) => { const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return Promise.resolve(undefined) if (!sessionID) return undefined
const [syncStore] = globalSync.child(directory, { bootstrap: false }) const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id) 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 return globalSDK.client.session
.get({ directory, sessionID }) .get({ directory, sessionID })
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .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 unsub = globalSDK.event.listen((e) => {
const event = e.details const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name const directory = e.name
const time = Date.now() const time = Date.now()
const viewed = (sessionID?: string) => { if (event.type === "session.idle") {
const activeDirectory = currentDirectory() handleSessionIdle(directory, event, time)
const activeSession = currentSession() return
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
}
} }
handleSessionError(directory, event, time)
}) })
onCleanup(() => { onCleanup(() => {
meta.disposed = true meta.disposed = true

View File

@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
return false return false
} }
function hasAutoAcceptPermissionConfig(permission: unknown) { function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false if (!permission) return false
if (typeof permission === "string") return permission !== "allow" if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false if (typeof permission !== "object") return false
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const directory = decode64(params.dir) const directory = decode64(params.dir)
if (!directory) return false if (!directory) return false
const [store] = globalSync.child(directory) const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission) return hasPermissionPromptRules(store.config.permission)
}) })
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const MAX_RESPONDED = 1000 const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>() const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
function pruneResponded(now: number) { function pruneResponded(now: number) {
for (const [id, ts] of responded) { 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 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 unsubscribe = globalSDK.event.listen((e) => {
const event = e.details const event = e.details
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
function enable(sessionID: string, directory: string) { function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.autoAcceptEdits[key] = true draft.autoAcceptEdits[key] = true
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
globalSDK.client.permission globalSDK.client.permission
.list({ directory }) .list({ directory })
.then((x) => { .then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) { for (const perm of x.data ?? []) {
if (!perm?.id) continue if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue if (perm.sessionID !== sessionID) continue
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
} }
function disable(sessionID: string, directory?: string) { function disable(sessionID: string, directory?: string) {
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : undefined const key = directory ? acceptKey(sessionID, directory) : undefined
setStore( setStore(
produce((draft) => { produce((draft) => {

View File

@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" 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 = { export type Platform = {
/** Platform discriminator */ /** Platform discriminator */
platform: "web" | "desktop" platform: "web" | "desktop"
@@ -31,19 +37,19 @@ export type Platform = {
notify(title: string, description?: string, href?: string): Promise<void> notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */ /** 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) */ /** 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) */ /** 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 mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */ /** Check for updates (Tauri only) */
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }> checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */ /** Install updates (Tauri only) */
update?(): Promise<void> update?(): Promise<void>

View File

@@ -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 { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" 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 { export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) { for (let i = 0; i < promptA.length; i++) {
const partA = promptA[i] if (!isPartEqual(promptA[i], promptB[i])) return false
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
}
} }
return true return true
} }
@@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart) 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 WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20 const MAX_PROMPT_SESSIONS = 20
@@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) {
}), }),
) )
function keyForItem(item: ContextItem) { const actions = createPromptActions(setStore)
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)}`
}
return { return {
ready, ready,
@@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) {
context: { context: {
items: createMemo(() => store.context.items), items: createMemo(() => store.context.items),
add(item: ContextItem) { add(item: ContextItem) {
const key = keyForItem(item) const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }]) 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)) setStore("context", "items", (items) => items.filter((x) => x.key !== key))
}, },
}, },
set(prompt: Prompt, cursorPosition?: number) { set: actions.set,
const next = clonePrompt(prompt) reset: actions.reset,
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
} }
} }

View File

@@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { directory: Accessor<string> }) => { init: (props: { directory: Accessor<string> }) => {
@@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}), }),
) )
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<SDKEventMap>()
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
createEffect(() => { createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => { const unsub = globalSDK.event.on(directory(), (event) => {

View File

@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health" import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) { export function normalizeServerUrl(input: string) {
const trimmed = input.trim() const trimmed = input.trim()
@@ -48,24 +49,38 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const healthy = () => state.healthy const healthy = () => state.healthy
function setActive(input: string) { const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
const url = normalizeServerUrl(input)
if (!url) return function reconcileStartup() {
setState("active", url) const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
} }
function add(input: string) { function updateServerList(url: string, remove = false) {
const url = normalizeServerUrl(input) if (remove) {
if (!url) return const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) {
batch(() => { batch(() => {
if (!store.list.includes(url)) { setStore("list", list)
// Add the fallback url to the list if it's not already in the list setState("active", next)
setStore("list", store.list.length, url)
}
setState("active", url)
}) })
return return
} }
@@ -78,51 +93,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}) })
} }
function remove(input: string) { function startHealthPolling(url: 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)
})
}
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)
}
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)
let alive = true let alive = true
let busy = false let busy = false
@@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
run() run()
const interval = setInterval(run, 10_000) const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
return () => {
onCleanup(() => {
alive = false alive = false
clearInterval(interval) 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)) const origin = createMemo(() => projectsKey(state.active))

View File

@@ -85,6 +85,10 @@ export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font] 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({ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings", name: "Settings",
init: () => { init: () => {
@@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
return store return store
}, },
general: { general: {
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave), autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
setAutoSave(value: boolean) { setAutoSave(value: boolean) {
setStore("general", "autoSave", value) setStore("general", "autoSave", value)
}, },
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes), releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
setReleaseNotes(value: boolean) { setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value) setStore("general", "releaseNotes", value)
}, },
}, },
updates: { updates: {
startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup), startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) { setStartup(value: boolean) {
setStore("updates", "startup", value) setStore("updates", "startup", value)
}, },
}, },
appearance: { appearance: {
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) { setFontSize(value: number) {
setStore("appearance", "fontSize", value) setStore("appearance", "fontSize", value)
}, },
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font), font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
setFont(value: string) { setFont(value: string) {
setStore("appearance", "font", value) setStore("appearance", "font", value)
}, },
@@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("keybinds", action, keybind) setStore("keybinds", action, keybind)
}, },
reset(action: string) { 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() { resetAll() {
setStore("keybinds", reconcile({})) setStore("keybinds", reconcile({}))
}, },
}, },
permissions: { permissions: {
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove), autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) { setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value) setStore("permissions", "autoApprove", value)
}, },
}, },
notifications: { notifications: {
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent), agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) { setAgent(value: boolean) {
setStore("notifications", "agent", value) setStore("notifications", "agent", value)
}, },
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions), permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
setPermissions(value: boolean) { setPermissions(value: boolean) {
setStore("notifications", "permissions", value) setStore("notifications", "permissions", value)
}, },
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors), errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
setErrors(value: boolean) { setErrors(value: boolean) {
setStore("notifications", "errors", value) setStore("notifications", "errors", value)
}, },
}, },
sounds: { sounds: {
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent), agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) { setAgent(value: string) {
setStore("sounds", "agent", value) setStore("sounds", "agent", value)
}, },
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions), permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) { setPermissions(value: string) {
setStore("sounds", "permissions", value) setStore("sounds", "permissions", value)
}, },
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors), errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) { setErrors(value: string) {
setStore("sounds", "errors", value) setStore("sounds", "errors", value)
}, },

View File

@@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" 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 keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) 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) const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message) 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) { export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
@@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID] 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({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
init: () => { init: () => {
@@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory) return globalSync.child(directory)
} }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400 const messagePageSize = 400
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = 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) => { const limitFor = (count: number) => {
if (count <= chunk) return chunk if (count <= messagePageSize) return messagePageSize
return Math.ceil(count / chunk) * chunk 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: { const loadMessages = async (input: {
@@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return if (meta.loading[key]) return
setMeta("loading", key, true) setMeta("loading", key, true)
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit })) await fetchMessages(input)
.then((messages) => { .then((next) => {
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))
batch(() => { batch(() => {
input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const message of next.part) {
for (const message of items) { input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
input.setStore(
"part",
message.info.id,
reconcile(
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
} }
setMeta("limit", key, input.limit) setMeta("limit", key, input.limit)
setMeta("complete", key, next.length < input.limit) setMeta("complete", key, next.complete)
}) })
}) })
.finally(() => { .finally(() => {
@@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const [, setStore] = target(input.directory) const [, setStore] = target(input.directory)
setStore( setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
produce((draft) => {
applyOptimisticAdd(draft as OptimisticStore, input)
}),
)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { remove(input: { directory?: string; sessionID: string; messageID: string }) {
const [, setStore] = target(input.directory) const [, setStore] = target(input.directory)
setStore( setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
produce((draft) => {
applyOptimisticRemove(draft as OptimisticStore, input)
}),
)
}, },
}, },
addOptimisticMessage(input: { addOptimisticMessage(input: {
@@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
model: input.model, model: input.model,
} }
const [, setStore] = target() const [, setStore] = target()
setStore( setOptimisticAdd(setStore as (...args: unknown[]) => void, {
produce((draft) => { sessionID: input.sessionID,
applyOptimisticAdd(draft as OptimisticStore, { message,
sessionID: input.sessionID, parts: input.parts,
message, })
parts: input.parts,
})
}),
)
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
@@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return if (hasSession && hasMessages && hydrated) return
const pending = inflight.get(key)
if (pending) return pending
const count = store.message[sessionID]?.length ?? 0 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 const sessionReq = hasSession
? Promise.resolve() ? Promise.resolve()
@@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit, limit,
}) })
const promise = Promise.all([sessionReq, messagesReq]) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
.then(() => {})
.finally(() => {
inflight.delete(key)
})
inflight.set(key, promise)
return promise
}, },
async diff(sessionID: string) { async diff(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
@@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.session_diff[sessionID] !== undefined) return if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const pending = inflightDiff.get(key) return runInflight(inflightDiff, key, () =>
if (pending) return pending retry(() => client.session.diff({ sessionID })).then((diff) => {
const promise = retry(() => client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}) }),
.finally(() => { )
inflightDiff.delete(key)
})
inflightDiff.set(key, promise)
return promise
}, },
async todo(sessionID: string) { async todo(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
@@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.todo[sessionID] !== undefined) return if (store.todo[sessionID] !== undefined) return
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const pending = inflightTodo.get(key) return runInflight(inflightTodo, key, () =>
if (pending) return pending retry(() => client.session.todo({ sessionID })).then((todo) => {
const promise = retry(() => client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
}) }),
.finally(() => { )
inflightTodo.delete(key)
})
inflightTodo.set(key, promise)
return promise
}, },
history: { history: {
more(sessionID: string) { more(sessionID: string) {
@@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(sdk.directory, sessionID) const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false return meta.loading[key] ?? false
}, },
async loadMore(sessionID: string, count = chunk) { async loadMore(sessionID: string, count = messagePageSize) {
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [, setStore] = globalSync.child(directory) const [, setStore] = globalSync.child(directory)
@@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return if (meta.loading[key]) return
if (meta.complete[key]) return if (meta.complete[key]) return
const currentLimit = meta.limit[key] ?? chunk const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({ await loadMessages({
directory, directory,
client, client,

View File

@@ -79,19 +79,38 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}), }),
) )
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { const pickNextTerminalNumber = () => {
const id = event.properties.id const existingTitleNumbers = new Set(
if (!store.all.some((x) => x.id === id)) return 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(() => { batch(() => {
setStore( setStore("all", filtered)
"all", setStore("active", active)
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)
}
}) })
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
}) })
onCleanup(unsub) onCleanup(unsub)
@@ -117,7 +136,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
return { return {
ready, ready,
all: createMemo(() => Object.values(store.all)), all: createMemo(() => store.all),
active: createMemo(() => store.active), active: createMemo(() => store.active),
clear() { clear() {
batch(() => { batch(() => {
@@ -126,20 +145,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
new() { new() {
const existingTitleNumbers = new Set( const nextNumber = pickNextTerminalNumber()
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
sdk.client.pty sdk.client.pty
.create({ title: `Terminal ${nextNumber}` }) .create({ title: `Terminal ${nextNumber}` })
@@ -162,10 +168,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
const index = store.all.findIndex((x) => x.id === pty.id) const previous = store.all.find((x) => x.id === pty.id)
if (index !== -1) { if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
setStore("all", index, (existing) => ({ ...existing, ...pty }))
}
sdk.client.pty sdk.client.pty
.update({ .update({
ptyID: pty.id, 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, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (previous) {
setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
}
console.error("Failed to update terminal", error) console.error("Failed to update terminal", error)
}) })
}, },

View File

@@ -8,97 +8,117 @@ import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
const root = document.getElementById("root") const getLocale = () => {
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (typeof navigator !== "object") return "en" as const
const locale = (() => { const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
if (typeof navigator !== "object") return "en" as const for (const language of languages) {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language] if (!language) continue
for (const language of languages) { if (language.toLowerCase().startsWith("zh")) return "zh" as const
if (!language) continue }
if (language.toLowerCase().startsWith("zh")) return "zh" as const return "en" as const
} }
return "en" as const
})()
const getRootNotFoundError = () => {
const key = "error.dev.rootNotFound" as const const key = "error.dev.rootNotFound" as const
const message = locale === "zh" ? (zh[key] ?? en[key]) : en[key] const locale = getLocale()
throw new Error(message) 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 = { const platform: Platform = {
platform: "web", platform: "web",
version: pkg.version, version: pkg.version,
openLink(url: string) { openLink,
window.open(url, "_blank") back,
}, forward,
back() { restart,
window.history.back() notify,
}, getDefaultServerUrl: readDefaultServerUrl,
forward() { setDefaultServerUrl: writeDefaultServerUrl,
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
}
},
} }
render( if (root instanceof HTMLElement) {
() => ( render(
<PlatformProvider value={platform}> () => (
<AppBaseProviders> <PlatformProvider value={platform}>
<AppInterface /> <AppBaseProviders>
</AppBaseProviders> <AppInterface />
</PlatformProvider> </AppBaseProviders>
), </PlatformProvider>
root!, ),
) root,
)
}

View File

@@ -1,3 +1,5 @@
import "solid-js"
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
@@ -6,3 +8,11 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
} }
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
}
}
}

View File

@@ -1,21 +1,47 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2" import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" 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) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const language = useLanguage() const language = useLanguage()
let invalid = "" const [store, setStore] = createStore({ invalid: "" })
const directory = createMemo(() => { const directory = createMemo(() => {
return decode64(params.dir) ?? "" return decode64(params.dir) ?? ""
}) })
@@ -23,8 +49,8 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
if (!params.dir) return if (!params.dir) return
if (directory()) return if (directory()) return
if (invalid === params.dir) return if (store.invalid === params.dir) return
invalid = params.dir setStore("invalid", params.dir)
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
@@ -36,46 +62,7 @@ export default function Layout(props: ParentProps) {
<Show when={directory()}> <Show when={directory()}>
<SDKProvider directory={directory}> <SDKProvider directory={directory}>
<SyncProvider> <SyncProvider>
{iife(() => { <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
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>
)
})}
</SyncProvider> </SyncProvider>
</SDKProvider> </SDKProvider>
</Show> </Show>

View File

@@ -13,6 +13,17 @@ export type InitError = {
} }
type Translator = ReturnType<typeof useLanguage>["t"] 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 { function isInitError(error: unknown): error is InitError {
return ( return (
@@ -112,9 +123,7 @@ function formatInitError(error: InitError, t: Translator): string {
} }
case "ConfigInvalidError": { case "ConfigInvalidError": {
const issues = Array.isArray(data.issues) const issues = Array.isArray(data.issues)
? data.issues.map( ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
)
: [] : []
const message = typeof data.message === "string" ? data.message : "" const message = typeof data.message === "string" ? data.message : ""
const path = typeof data.path === "string" ? data.path : safeJson(data.path) 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)) { if (isInitError(error)) {
const message = formatInitError(error, t) const message = formatInitError(error, t)
if (depth > 0 && parentMessage === message) return "" 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}` return indent + `${error.name}\n${message}`
} }
if (error instanceof Error) { if (error instanceof Error) {
const isDuplicate = depth > 0 && parentMessage === error.message const isDuplicate = depth > 0 && parentMessage === error.message
const parts: string[] = [] 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 header = `${error.name}${error.message ? `: ${error.message}` : ""}`
const stack = error.stack?.trim() const stack = error.stack?.trim()
@@ -190,11 +199,11 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
if (typeof error === "string") { if (typeof error === "string") {
if (depth > 0 && parentMessage === error) return "" 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 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) return indent + safeJson(error)
} }
@@ -212,20 +221,35 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
checking: false, checking: false,
version: undefined as string | undefined, version: undefined as string | undefined,
actionError: undefined as string | undefined,
}) })
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
const result = await platform.checkUpdate() await platform
setStore("checking", false) .checkUpdate()
if (result.updateAvailable && result.version) setStore("version", result.version) .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() { async function installUpdate() {
if (!platform.update || !platform.restart) return if (!platform.update || !platform.restart) return
await platform.update() await platform
await platform.restart() .update()
.then(() => platform.restart!())
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
} }
return ( return (
@@ -266,6 +290,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
</Show> </Show>
</Show> </Show>
</div> </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 flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center justify-center gap-1">
{language.t("error.page.report.prefix")} {language.t("error.page.report.prefix")}

View File

@@ -30,6 +30,13 @@ export default function Home() {
.slice(0, 5) .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) { function openProject(directory: string) {
layout.projects.open(directory) layout.projects.open(directory)
server.projects.touch(directory) server.projects.touch(directory)
@@ -73,9 +80,7 @@ export default function Home() {
<div <div
classList={{ classList={{
"size-2 rounded-full": true, "size-2 rounded-full": true,
"bg-icon-success-base": server.healthy() === true, [serverDotClass()]: true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}} }}
/> />
{server.name} {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-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 class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div> </div>
<div /> <Button class="px-3 mt-1" onClick={chooseProject}>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")} {language.t("command.project.open")}
</Button> </Button>
</div> </div>

View File

@@ -207,6 +207,18 @@ export default function Layout(props: ParentProps) {
const setEditor = editor.setEditor const setEditor = editor.setEditor
const InlineEditor = editor.InlineEditor 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) { function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id) const ids = availableThemeEntries().map(([id]) => id)
if (ids.length === 0) return if (ids.length === 0) return
@@ -252,166 +264,167 @@ export default function Layout(props: ParentProps) {
setLocale(next) setLocale(next)
} }
onMount(() => { const useUpdatePolling = () =>
if (!platform.checkUpdate || !platform.update || !platform.restart) return onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
let toastId: number | undefined let toastId: number | undefined
let interval: ReturnType<typeof setInterval> | undefined let interval: ReturnType<typeof setInterval> | undefined
async function pollUpdate() { const pollUpdate = () =>
const { updateAvailable, version } = await platform.checkUpdate!() platform.checkUpdate!().then(({ updateAvailable, version }) => {
if (updateAvailable && toastId === undefined) { if (!updateAvailable) return
toastId = showToast({ if (toastId !== undefined) return
persistent: true, toastId = showToast({
icon: "download", persistent: true,
title: language.t("toast.update.title"), icon: "download",
description: language.t("toast.update.description", { version: version ?? "" }), title: language.t("toast.update.title"),
actions: [ description: language.t("toast.update.description", { version: version ?? "" }),
{ actions: [
label: language.t("toast.update.action.installRestart"), {
onClick: async () => { label: language.t("toast.update.action.installRestart"),
await platform.update!() onClick: async () => {
await platform.restart!() await platform.update!()
await platform.restart!()
},
}, },
}, {
{ label: language.t("toast.update.action.notYet"),
label: language.t("toast.update.action.notYet"), onClick: "dismiss",
onClick: "dismiss", },
}, ],
], })
}) })
}
}
createEffect(() => { createEffect(() => {
if (!settings.ready()) return 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 if (interval === undefined) return
clearInterval(interval) clearInterval(interval)
interval = undefined
return
}
if (interval !== undefined) return
void pollUpdate()
interval = setInterval(pollUpdate, 10 * 60 * 1000)
})
onCleanup(() => {
if (interval === undefined) return
clearInterval(interval)
})
})
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
}
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 !== "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 === "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
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",
},
],
}) })
toastBySession.set(sessionKey, toastId)
}) })
onCleanup(unsub)
createEffect(() => { const useSDKNotificationToasts = () =>
const currentSession = params.id onMount(() => {
if (!currentDir() || !currentSession) return const toastBySession = new Map<string, number>()
const sessionKey = `${currentDir()}:${currentSession}` const alertedAtBySession = new Map<string, number>()
const toastId = toastBySession.get(sessionKey) const cooldownMs = 5000
if (toastId !== undefined) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
toaster.dismiss(toastId) toaster.dismiss(toastId)
toastBySession.delete(sessionKey) toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey)
} }
const [store] = globalSync.child(currentDir(), { bootstrap: false })
const childSessions = store.session.filter((s) => s.parentID === currentSession) const unsub = globalSDK.event.listen((e) => {
for (const child of childSessions) { if (e.details?.type === "worktree.ready") {
const childKey = `${currentDir()}:${child.id}` setBusy(e.name, false)
const childToastId = toastBySession.get(childKey) WorktreeState.ready(e.name)
if (childToastId !== undefined) { return
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
alertedAtBySession.delete(childKey)
} }
}
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 !== "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 === "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)
})
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) { function scrollToSession(sessionId: string, sessionKey: string) {
if (!scrollContainerRef) return if (!scrollContainerRef) return
@@ -641,6 +654,21 @@ export default function Layout(props: ParentProps) {
return created 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) { async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = globalSync.child(directory, { bootstrap: false }) const [store, setStore] = globalSync.child(directory, { bootstrap: false })
@@ -649,51 +677,24 @@ export default function Layout(props: ParentProps) {
if (prefetchToken.value !== token) return if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
.map((x) => x.info) const sorted = mergeByID([], next)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const current = store.message[sessionID] ?? [] const current = store.message[sessionID] ?? []
const merged = (() => { const merged = mergeByID(
if (current.length === 0) return next current.filter((item): item is Message => !!item?.id),
sorted,
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))
})()
batch(() => { batch(() => {
setStore("message", sessionID, reconcile(merged, { key: "id" })) setStore("message", sessionID, reconcile(merged, { key: "id" }))
for (const message of items) { for (const message of items) {
const currentParts = store.part[message.info.id] ?? [] const currentParts = store.part[message.info.id] ?? []
const mergedParts = (() => { const mergedParts = mergeByID(
if (currentParts.length === 0) { currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
return message.parts message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
.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))
})()
setStore("part", message.info.id, reconcile(mergedParts, { key: "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) { function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
server.projects.touch(directory) server.projects.touch(directory)
const lastSession = store.lastSession[directory] const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
layout.mobileSidebar.hide()
} }
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
if (!layout.sidebar.opened()) { navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide()
} }
function openProject(directory: string, navigate = true) { function openProject(directory: string, navigate = true) {
@@ -1555,10 +1546,7 @@ export default function Layout(props: ParentProps) {
} }
const createWorkspace = async (project: LocalProject) => { const createWorkspace = async (project: LocalProject) => {
if (!layout.sidebar.opened()) { clearSidebarHoverState()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
const created = await globalSDK.client.worktree const created = await globalSDK.client.worktree
.create({ directory: project.worktree }) .create({ directory: project.worktree })
.then((x) => x.data) .then((x) => x.data)
@@ -1595,8 +1583,7 @@ export default function Layout(props: ParentProps) {
}) })
globalSync.child(created.directory) globalSync.child(created.directory)
navigate(`/${base64Encode(created.directory)}/session`) navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
layout.mobileSidebar.hide()
} }
const workspaceSidebarCtx: WorkspaceSidebarContext = { const workspaceSidebarCtx: WorkspaceSidebarContext = {
@@ -1772,14 +1759,7 @@ export default function Layout(props: ParentProps) {
size="large" size="large"
icon="plus-small" icon="plus-small"
class="w-full" class="w-full"
onClick={() => { onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(p().worktree)}/session`)
layout.mobileSidebar.hide()
}}
> >
{language.t("command.session.new")} {language.t("command.session.new")}
</Button> </Button>

View File

@@ -1,8 +1,9 @@
import { createStore } from "solid-js/store" 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" import { InlineInput } from "@opencode-ai/ui/inline-input"
export function createInlineEditorController() { export function createInlineEditorController() {
// This controller intentionally supports one active inline editor at a time.
const [editor, setEditor] = createStore({ const [editor, setEditor] = createStore({
active: "" as string, active: "" as string,
value: "", value: "",
@@ -47,6 +48,13 @@ export function createInlineEditorController() {
stopPropagation?: boolean stopPropagation?: boolean
openOnDblClick?: boolean openOnDblClick?: boolean
}) => { }) => {
let frame: number | undefined
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
const isEditing = () => props.editing ?? editorOpen(props.id) const isEditing = () => props.editing ?? editorOpen(props.id)
const stopEvents = () => props.stopPropagation ?? false const stopEvents = () => props.stopPropagation ?? false
const allowDblClick = () => props.openOnDblClick ?? true const allowDblClick = () => props.openOnDblClick ?? true
@@ -78,7 +86,12 @@ export function createInlineEditorController() {
> >
<InlineInput <InlineInput
ref={(el) => { ref={(el) => {
requestAnimationFrame(() => el.focus()) if (frame !== undefined) cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
frame = undefined
if (!el.isConnected) return
el.focus()
})
}} }}
value={editorValue()} value={editorValue()}
class={props.class} class={props.class}

View File

@@ -13,7 +13,7 @@ import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/util/path" 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 { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { agentColor } from "@/utils/agent" import { agentColor } from "@/utils/agent"
@@ -70,6 +70,116 @@ export type SessionItemProps = {
archiveSession: (session: Session) => Promise<void> 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 => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const hoverMessages = createMemo(() => 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 hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) 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) const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text return text?.text
} }
const item = ( const item = (
<A <SessionRow
href={`/${props.slug}/session/${props.session.id}`} session={props.session}
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"}`} slug={props.slug}
onPointerEnter={scheduleHoverPrefetch} mobile={props.mobile}
onPointerLeave={cancelHoverPrefetch} dense={props.dense}
onMouseEnter={scheduleHoverPrefetch} tint={tint}
onMouseLeave={cancelHoverPrefetch} isWorking={isWorking}
onFocus={() => props.prefetchSession(props.session, "high")} hasPermissions={hasPermissions}
onClick={() => { hasError={hasError}
props.setHoverSession(undefined) unseenCount={unseenCount}
if (layout.sidebar.opened()) return setHoverSession={props.setHoverSession}
props.clearHoverProjectSoon() clearHoverProjectSoon={props.clearHoverProjectSoon}
}} sidebarOpened={layout.sidebar.opened}
> prefetchSession={props.prefetchSession}
<div class="flex items-center gap-1 w-full"> scheduleHoverPrefetch={scheduleHoverPrefetch}
<div cancelHoverPrefetch={cancelHoverPrefetch}
class="shrink-0 size-6 flex items-center justify-center" />
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
) )
return ( return (
@@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Tooltip> </Tooltip>
} }
> >
<HoverCard <SessionHoverPreview
openDelay={1000} mobile={props.mobile}
closeDelay={props.sidebarHovering() ? 600 : 0} nav={props.nav}
placement="right-start" hoverSession={props.hoverSession}
gutter={16} session={props.session}
shift={-2} 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} 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> </Show>
<div <div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}

View File

@@ -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: { export const SortableProject = (props: {
project: LocalProject project: LocalProject
mobile?: boolean mobile?: boolean
@@ -105,177 +294,61 @@ export const SortableProject = (props: {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session) return childMapByParent(data.session)
} }
const trigger = (
const Trigger = () => ( <ProjectTile
<ContextMenu project={props.project}
modal={!props.ctx.sidebarHovering()} mobile={props.mobile}
onOpenChange={(value) => { nav={props.ctx.nav}
setMenu(value) sidebarHovering={props.ctx.sidebarHovering}
if (value) setOpen(false) selected={selected}
}} active={active}
> overlay={overlay}
<ContextMenu.Trigger onProjectMouseEnter={props.ctx.onProjectMouseEnter}
as="button" onProjectMouseLeave={props.ctx.onProjectMouseLeave}
type="button" onProjectFocus={props.ctx.onProjectFocus}
aria-label={displayName(props.project)} navigateToProject={props.ctx.navigateToProject}
data-action="project-switch" showEditProjectDialog={props.ctx.showEditProjectDialog}
data-project={base64Encode(props.project.worktree)} toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
classList={{ workspacesEnabled={props.ctx.workspacesEnabled}
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, closeProject={props.ctx.closeProject}
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), setMenu={setMenu}
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": setOpen={setOpen}
!selected() && !active(), language={language}
"bg-surface-base-hover border border-border-weak-base": !selected() && active(), />
}}
onMouseEnter={(event: MouseEvent) => {
if (!overlay()) return
props.ctx.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!overlay()) return
props.ctx.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!overlay()) return
props.ctx.onProjectFocus(props.project.worktree)
}}
onClick={() => props.ctx.navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.ctx.workspacesEnabled(props.project)
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.ctx.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
) )
return ( return (
// @ts-ignore // @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={<Trigger />}> <Show when={preview()} fallback={trigger}>
<HoverCard <HoverCard
open={open() && !menu()} open={open() && !menu()}
openDelay={0} openDelay={0}
closeDelay={0} closeDelay={0}
placement="right-start" placement="right-start"
gutter={6} gutter={6}
trigger={<Trigger />} trigger={trigger}
onOpenChange={(value) => { onOpenChange={(value) => {
if (menu()) return if (menu()) return
setOpen(value) setOpen(value)
if (value) props.ctx.setHoverSession(undefined) if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
<div class="-m-3 p-2 flex flex-col w-72"> <ProjectPreviewPanel
<div class="px-4 pt-2 pb-1 flex items-center gap-2"> project={props.project}
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div> mobile={props.mobile}
<Tooltip value={language.t("common.close")} placement="top" gutter={6}> selected={selected}
<IconButton workspaceEnabled={workspaceEnabled}
icon="circle-x" workspaces={workspaces}
variant="ghost" label={label}
class="shrink-0" projectSessions={projectSessions}
data-action="project-close-hover" projectChildren={projectChildren}
data-project={base64Encode(props.project.worktree)} workspaceSessions={workspaceSessions}
aria-label={language.t("common.close")} workspaceChildren={workspaceChildren}
onClick={(event) => { setOpen={setOpen}
event.stopPropagation() ctx={props.ctx}
setOpen(false) language={language}
props.ctx.closeProject(props.project.worktree) />
}}
/>
</Tooltip>
</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
fallback={
<For each={projectSessions()}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
popover={false}
children={projectChildren()}
/>
)}
</For>
}
>
<For each={workspaces()}>
{(directory) => {
const sessions = createMemo(() => workspaceSessions(directory))
const children = createMemo(() => workspaceChildren(directory))
return (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
</div>
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div>
<For each={sessions()}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
slug={base64Encode(directory)}
dense
mobile={props.mobile}
popover={false}
children={children()}
/>
)}
</For>
</div>
)
}}
</For>
</Show>
</div>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
setOpen(false)
if (selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
>
{language.t("sidebar.project.viewAllSessions")}
</Button>
</div>
</div>
</HoverCard> </HoverCard>
</Show> </Show>
</div> </div>

View File

@@ -34,6 +34,7 @@ export const SidebarContent = (props: {
renderPanel: () => JSX.Element renderPanel: () => JSX.Element
}): JSX.Element => { }): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right")
return ( return (
<div class="flex h-full w-full overflow-hidden"> <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> <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider> </SortableProvider>
<Tooltip <Tooltip
placement={props.mobile ? "bottom" : "right"} placement={placement()}
value={ value={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{props.openProjectLabel}</span> <span>{props.openProjectLabel}</span>
@@ -78,11 +79,7 @@ export const SidebarContent = (props: {
</DragDropProvider> </DragDropProvider>
</div> </div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<TooltipKeybind <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
placement={props.mobile ? "bottom" : "right"}
title={props.settingsLabel()}
keybind={props.settingsKeybind() ?? ""}
>
<IconButton <IconButton
icon="settings-gear" icon="settings-gear"
variant="ghost" variant="ghost"
@@ -91,7 +88,7 @@ export const SidebarContent = (props: {
aria-label={props.settingsLabel()} aria-label={props.settingsLabel()}
/> />
</TooltipKeybind> </TooltipKeybind>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}> <Tooltip placement={placement()} value={props.helpLabel()}>
<IconButton <IconButton
icon="help" icon="help"
variant="ghost" variant="ghost"

View File

@@ -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: { export const SortableWorkspace = (props: {
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
directory: string directory: string
@@ -135,46 +351,6 @@ export const SortableWorkspace = (props: {
globalSync.child(props.directory, { bootstrap: true }) 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 ( return (
<div <div
// @ts-ignore // @ts-ignore
@@ -202,7 +378,20 @@ export const SortableWorkspace = (props: {
data-action="workspace-toggle" data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)} 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> </Collapsible.Trigger>
} }
> >
@@ -211,139 +400,61 @@ export const SortableWorkspace = (props: {
menu.open ? "pr-16" : "pr-2" menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`} } 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> </div>
</Show> </Show>
<div <WorkspaceActions
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" directory={props.directory}
classList={{ local={local}
"opacity-100 pointer-events-auto": menu.open, busy={busy}
"opacity-0 pointer-events-none": !menu.open, menuOpen={() => menu.open}
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, pendingRename={() => menu.pendingRename}
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, setMenuOpen={(open) => setMenu("open", open)}
}} setPendingRename={(value) => setMenu("pendingRename", value)}
> sidebarHovering={props.ctx.sidebarHovering}
<DropdownMenu mobile={props.mobile}
modal={!props.ctx.sidebarHovering()} nav={props.ctx.nav}
open={menu.open} touch={touch}
onOpenChange={(open) => setMenu("open", open)} language={language}
> workspaceValue={workspaceValue}
<Tooltip value={language.t("common.moreOptions")} placement="top"> openEditor={props.ctx.openEditor}
<DropdownMenu.Trigger showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
as={IconButton} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
icon="dot-grid" root={props.project.worktree}
variant="ghost" setHoverSession={props.ctx.setHoverSession}
class="size-6 rounded-md" clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
data-action="workspace-menu" navigateToNewSession={() => navigate(`/${slug()}/session`)}
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>
</div> </div>
</div> </div>
</div> </div>
<Collapsible.Content> <Collapsible.Content>
<nav class="flex flex-col gap-1 px-2"> <WorkspaceSessionList
<Show when={showNew()}> slug={slug}
<NewSessionItem mobile={props.mobile}
slug={slug()} ctx={props.ctx}
mobile={props.mobile} showNew={showNew}
sidebarExpanded={props.ctx.sidebarExpanded} loading={loading}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} sessions={sessions}
setHoverSession={props.ctx.setHoverSession} children={children}
/> hasMore={hasMore}
</Show> loadMore={loadMore}
<Show when={loading()}> language={language}
<SessionSkeleton /> />
</Show>
<For each={sessions()}>
{(session) => (
<SessionItem
session={session}
slug={slug()}
mobile={props.mobile}
children={children()}
sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
/>
)}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
{language.t("common.loadMore")}
</Button>
</div>
</Show>
</nav>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
</div> </div>

View File

@@ -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) { async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID) const session = sync.session.get(sessionID)
if (!session) return if (!session) return
@@ -411,17 +424,7 @@ export default function Page() {
if (index !== -1) draft.session.splice(index, 1) if (index !== -1) draft.session.splice(index, 1)
}), }),
) )
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
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`)
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -487,16 +490,7 @@ export default function Page() {
}), }),
) )
if (params.id !== sessionID) return true navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
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`)
return true return true
} }
@@ -1532,15 +1526,18 @@ export default function Page() {
createEffect(() => { createEffect(() => {
if (!file.ready()) return if (!file.ready()) return
setSessionHandoff(sessionKey(), { setSessionHandoff(sessionKey(), {
files: Object.fromEntries( files: tabs()
tabs() .all()
.all() .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
.flatMap((tab) => { const path = file.pathFromTab(tab)
const path = file.pathFromTab(tab) if (!path) return acc
if (!path) return [] const selected = file.selectedLines(path)
return [[path, file.selectedLines(path) ?? null] as const] 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"> <div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs <SessionMobileTabs
open={!isDesktop() && !!params.id} open={!isDesktop() && !!params.id}
mobileTab={store.mobileTab}
hasReview={hasReview()} hasReview={hasReview()}
reviewCount={reviewCount()} reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")} onSession={() => setStore("mobileTab", "session")}
@@ -1719,7 +1717,6 @@ export default function Page() {
dialog={dialog} dialog={dialog}
file={file} file={file}
comments={comments} comments={comments}
sync={sync}
hasReview={hasReview()} hasReview={hasReview()}
reviewCount={reviewCount()} reviewCount={reviewCount()}
reviewTab={reviewTab()} reviewTab={reviewTab()}
@@ -1731,10 +1728,12 @@ export default function Page() {
openTab={openTab} openTab={openTab}
showAllFiles={showAllFiles} showAllFiles={showAllFiles}
reviewPanel={reviewPanel} reviewPanel={reviewPanel}
messages={messages as () => unknown[]} vm={{
visibleUserMessages={visibleUserMessages as () => unknown[]} messages,
view={view} visibleUserMessages,
info={info as () => unknown} view,
info,
}}
handoffFiles={() => handoff.session.get(sessionKey())?.files} handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent} codeComponent={codeComponent}
addCommentToContext={addCommentToContext} addCommentToContext={addCommentToContext}

View File

@@ -12,6 +12,13 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" 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: { export function FileTabContent(props: {
tab: string tab: string
activeTab: () => string activeTab: () => string
@@ -76,7 +83,6 @@ export function FileTabContent(props: {
showToast({ showToast({
variant: "error", variant: "error",
title: props.language.t("toast.file.loadFailed.title"), title: props.language.t("toast.file.loadFailed.title"),
description: "Invalid base64 content.",
}) })
}) })
const svgPreviewUrl = createMemo(() => { const svgPreviewUrl = createMemo(() => {
@@ -116,34 +122,6 @@ export function FileTabContent(props: {
draftTop: undefined as number | undefined, 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 getRoot = () => {
const el = wrap const el = wrap
if (!el) return if (!el) return
@@ -174,8 +152,8 @@ export function FileTabContent(props: {
const el = wrap const el = wrap
const root = getRoot() const root = getRoot()
if (!el || !root) { if (!el || !root) {
setPositions({}) setNote("positions", {})
setDraftTop(undefined) setNote("draftTop", undefined)
return return
} }
@@ -186,21 +164,21 @@ export function FileTabContent(props: {
next[comment.id] = markerTop(el, marker) next[comment.id] = markerTop(el, marker)
} }
setPositions(next) setNote("positions", next)
const range = commenting() const range = note.commenting
if (!range) { if (!range) {
setDraftTop(undefined) setNote("draftTop", undefined)
return return
} }
const marker = findMarker(root, range) const marker = findMarker(root, range)
if (!marker) { if (!marker) {
setDraftTop(undefined) setNote("draftTop", undefined)
return return
} }
setDraftTop(markerTop(el, marker)) setNote("draftTop", markerTop(el, marker))
} }
const scheduleComments = () => { const scheduleComments = () => {
@@ -213,10 +191,10 @@ export function FileTabContent(props: {
}) })
createEffect(() => { createEffect(() => {
const range = commenting() const range = note.commenting
scheduleComments() scheduleComments()
if (!range) return if (!range) return
setDraft("") setNote("draft", "")
}) })
createEffect(() => { createEffect(() => {
@@ -229,8 +207,8 @@ export function FileTabContent(props: {
const target = fileComments().find((comment) => comment.id === focus.id) const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return if (!target) return
setOpenedComment(target.id) setNote("openedComment", target.id)
setCommenting(null) setNote("commenting", null)
props.file.setSelectedLines(p, target.selection) props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus()) requestAnimationFrame(() => props.comments.clearFocus())
}) })
@@ -390,16 +368,16 @@ export function FileTabContent(props: {
const p = path() const p = path()
if (!p) return if (!p) return
props.file.setSelectedLines(p, range) props.file.setSelectedLines(p, range)
if (!range) setCommenting(null) if (!range) setNote("commenting", null)
}} }}
onLineSelectionEnd={(range: SelectedLineRange | null) => { onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) { if (!range) {
setCommenting(null) setNote("commenting", null)
return return
} }
setOpenedComment(null) setNote("openedComment", null)
setCommenting(range) setNote("commenting", range)
}} }}
overflow="scroll" overflow="scroll"
class="select-text" class="select-text"
@@ -408,10 +386,10 @@ export function FileTabContent(props: {
{(comment) => ( {(comment) => (
<LineCommentView <LineCommentView
id={comment.id} id={comment.id}
top={positions()[comment.id]} top={note.positions[comment.id]}
open={openedComment() === comment.id} open={note.openedComment === comment.id}
comment={comment.comment} comment={comment.comment}
selection={commentLabel(comment.selection)} selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => { onMouseEnter={() => {
const p = path() const p = path()
if (!p) return if (!p) return
@@ -420,22 +398,22 @@ export function FileTabContent(props: {
onClick={() => { onClick={() => {
const p = path() const p = path()
if (!p) return if (!p) return
setCommenting(null) setNote("commenting", null)
setOpenedComment((current) => (current === comment.id ? null : comment.id)) setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
props.file.setSelectedLines(p, comment.selection) props.file.setSelectedLines(p, comment.selection)
}} }}
/> />
)} )}
</For> </For>
<Show when={commenting()}> <Show when={note.commenting}>
{(range) => ( {(range) => (
<Show when={draftTop() !== undefined}> <Show when={note.draftTop !== undefined}>
<LineCommentEditor <LineCommentEditor
top={draftTop()} top={note.draftTop}
value={draft()} value={note.draft}
selection={commentLabel(range())} selection={formatCommentLabel(range())}
onInput={(value) => setDraft(value)} onInput={(value) => setNote("draft", value)}
onCancel={() => setCommenting(null)} onCancel={() => setNote("commenting", null)}
onSubmit={(value) => { onSubmit={(value) => {
const p = path() const p = path()
if (!p) return if (!p) return
@@ -445,7 +423,7 @@ export function FileTabContent(props: {
comment: value, comment: value,
origin: "file", origin: "file",
}) })
setCommenting(null) setNote("commenting", null)
}} }}
onPopoverFocusOut={(e: FocusEvent) => { onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement const current = e.currentTarget as HTMLDivElement
@@ -454,7 +432,7 @@ export function FileTabContent(props: {
setTimeout(() => { setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) { if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null) setNote("commenting", null)
} }
}, 0) }, 0)
}} }}

View File

@@ -9,6 +9,37 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" 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: { export function MessageTimeline(props: {
mobileChanges: boolean mobileChanges: boolean
mobileFallback: JSX.Element mobileFallback: JSX.Element
@@ -86,35 +117,13 @@ export function MessageTimeline(props: {
ref={props.setScrollRef} ref={props.setScrollRef}
onWheel={(e) => { onWheel={(e) => {
const root = e.currentTarget 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({ const delta = normalizeWheelDelta({
deltaY: e.deltaY, deltaY: e.deltaY,
deltaMode: e.deltaMode, deltaMode: e.deltaMode,
rootHeight: root.clientHeight, rootHeight: root.clientHeight,
}) })
if (!delta) return if (!delta) return
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
if (
shouldMarkBoundaryGesture({
delta,
scrollTop: nested.scrollTop,
scrollHeight: nested.scrollHeight,
clientHeight: nested.clientHeight,
})
) {
props.onMarkScrollGesture(root)
}
}} }}
onTouchStart={(e) => { onTouchStart={(e) => {
touchGesture = e.touches[0]?.clientY touchGesture = e.touches[0]?.clientY
@@ -129,28 +138,7 @@ export function MessageTimeline(props: {
if (!delta) return if (!delta) return
const root = e.currentTarget const root = e.currentTarget
const target = e.target instanceof Element ? e.target : undefined markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
const nested = target?.closest("[data-scrollable]")
if (!nested || nested === root) {
props.onMarkScrollGesture(root)
return
}
if (!(nested instanceof HTMLElement)) {
props.onMarkScrollGesture(root)
return
}
if (
shouldMarkBoundaryGesture({
delta,
scrollTop: nested.scrollTop,
scrollHeight: nested.scrollHeight,
clientHeight: nested.clientHeight,
})
) {
props.onMarkScrollGesture(root)
}
}} }}
onTouchEnd={() => { onTouchEnd={() => {
touchGesture = undefined touchGesture = undefined

View File

@@ -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 type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
@@ -30,7 +31,7 @@ export interface SessionReviewTabProps {
} }
export function StickyAddButton(props: { children: JSX.Element }) { export function StickyAddButton(props: { children: JSX.Element }) {
const [stuck, setStuck] = createSignal(false) const [state, setState] = createStore({ stuck: false })
let button: HTMLDivElement | undefined let button: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {
@@ -43,7 +44,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
const handler = () => { const handler = () => {
const rect = node.getBoundingClientRect() const rect = node.getBoundingClientRect()
const scrollRect = scroll.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 }) scroll.addEventListener("scroll", handler, { passive: true })
@@ -60,7 +61,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
<div <div
ref={button} 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" 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} {props.children}
</div> </div>
@@ -78,7 +79,10 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
return sdk.client.file return sdk.client.file
.read({ path }) .read({ path })
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
})
} }
const restoreScroll = () => { const restoreScroll = () => {

View File

@@ -1,8 +1,9 @@
import { Match, Show, Switch } from "solid-js" import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
export function SessionMobileTabs(props: { export function SessionMobileTabs(props: {
open: boolean open: boolean
mobileTab: "session" | "changes"
hasReview: boolean hasReview: boolean
reviewCount: number reviewCount: number
onSession: () => void onSession: () => void
@@ -11,7 +12,7 @@ export function SessionMobileTabs(props: {
}) { }) {
return ( return (
<Show when={props.open}> <Show when={props.open}>
<Tabs class="h-auto"> <Tabs value={props.mobileTab} class="h-auto">
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}> <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
{props.t("session.tab.session")} {props.t("session.tab.session")}
@@ -22,12 +23,9 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={props.onChanges} onClick={props.onChanges}
> >
<Switch> {props.hasReview
<Match when={props.hasReview}> ? props.t("session.review.filesChanged", { count: props.reviewCount })
{props.t("session.review.filesChanged", { count: props.reviewCount })} : props.t("session.review.change.other")}
</Match>
<Match when={true}>{props.t("session.review.change.other")}</Match>
</Switch>
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>

View File

@@ -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 { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool" import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock" import { QuestionDock } from "@/components/question-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers" import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
export function SessionPromptDock(props: { export function SessionPromptDock(props: {
centered: boolean centered: boolean
questionRequest: () => { questions: unknown[] } | undefined questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean blocked: boolean
promptReady: boolean promptReady: boolean
@@ -48,7 +47,7 @@ export function SessionPromptDock(props: {
subtitle, subtitle,
}} }}
/> />
<QuestionDock request={questionDockRequest(req)} /> <QuestionDock request={req} />
</div> </div>
) )
}} }}

View File

@@ -21,6 +21,14 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" 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: { export function SessionSidePanel(props: {
open: boolean open: boolean
@@ -31,7 +39,6 @@ export function SessionSidePanel(props: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile> file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments> comments: ReturnType<typeof useComments>
sync: ReturnType<typeof useSync>
hasReview: boolean hasReview: boolean
reviewCount: number reviewCount: number
reviewTab: boolean reviewTab: boolean
@@ -43,10 +50,7 @@ export function SessionSidePanel(props: {
openTab: (value: string) => void openTab: (value: string) => void
showAllFiles: () => void showAllFiles: () => void
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
messages: () => unknown[] vm: SessionSidePanelViewModel
visibleUserMessages: () => unknown[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => unknown
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent> codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: { addCommentToContext: (input: {
@@ -187,10 +191,10 @@ export function SessionSidePanel(props: {
<Show when={props.activeTab() === "context"}> <Show when={props.activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab <SessionContextTab
messages={props.messages as never} messages={props.vm.messages}
visibleUserMessages={props.visibleUserMessages as never} visibleUserMessages={props.vm.visibleUserMessages}
view={props.view as never} view={props.vm.view}
info={props.info as never} info={props.vm.info}
/> />
</div> </div>
</Show> </Show>
@@ -203,7 +207,7 @@ export function SessionSidePanel(props: {
tab={tab} tab={tab}
activeTab={props.activeTab} activeTab={props.activeTab}
tabs={props.tabs} tabs={props.tabs}
view={props.view} view={props.vm.view}
handoffFiles={props.handoffFiles} handoffFiles={props.handoffFiles}
file={props.file} file={props.file}
comments={props.comments} comments={props.comments}

View File

@@ -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 { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -141,9 +141,8 @@ export function TerminalPanel(props: {
<DragOverlay> <DragOverlay>
<Show when={props.activeTerminalDraggable()}> <Show when={props.activeTerminalDraggable()}>
{(draggedId) => { {(draggedId) => {
const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return ( return (
<Show when={pty()}> <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}>
{(t) => ( {(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({ {terminalTabLabel({

View File

@@ -1,8 +1,8 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" 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 { 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 { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -22,7 +22,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { combineCommandSections } from "@/pages/session/helpers" import { combineCommandSections } from "@/pages/session/helpers"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers" import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export const useSessionCommands = (input: { export type SessionCommandContext = {
command: ReturnType<typeof useCommand> command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile> file: ReturnType<typeof useFile>
@@ -49,32 +49,48 @@ export const useSessionCommands = (input: {
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => 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(() => [ const sessionCommands = createMemo(() => [
{ sessionCommand({
id: "session.new", id: "session.new",
title: input.language.t("command.session.new"), title: input.language.t("command.session.new"),
category: input.language.t("command.category.session"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => input.navigate(`/${input.params.dir}/session`), onSelect: () => input.navigate(`/${input.params.dir}/session`),
}, }),
]) ])
const fileCommands = createMemo(() => [ const fileCommands = createMemo(() => [
{ fileCommand({
id: "file.open", id: "file.open",
title: input.language.t("command.file.open"), title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"), description: input.language.t("palette.search.placeholder"),
category: input.language.t("command.category.file"),
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />), onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}, }),
{ fileCommand({
id: "tab.close", id: "tab.close",
title: input.language.t("command.tab.close"), title: input.language.t("command.tab.close"),
category: input.language.t("command.category.file"),
keybind: "mod+w", keybind: "mod+w",
disabled: !input.tabs().active(), disabled: !input.tabs().active(),
onSelect: () => { onSelect: () => {
@@ -82,15 +98,14 @@ export const useSessionCommands = (input: {
if (!active) return if (!active) return
input.tabs().close(active) input.tabs().close(active)
}, },
}, }),
]) ])
const contextCommands = createMemo(() => [ const contextCommands = createMemo(() => [
{ contextCommand({
id: "context.addSelection", id: "context.addSelection",
title: input.language.t("command.context.addSelection"), title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"), description: input.language.t("command.context.addSelection.description"),
category: input.language.t("command.category.context"),
keybind: "mod+shift+l", keybind: "mod+shift+l",
disabled: !canAddSelectionContext({ disabled: !canAddSelectionContext({
active: input.tabs().active(), active: input.tabs().active(),
@@ -103,7 +118,7 @@ export const useSessionCommands = (input: {
const path = input.file.pathFromTab(active) const path = input.file.pathFromTab(active)
if (!path) return if (!path) return
const range = input.file.selectedLines(path) const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) { if (!range) {
showToast({ showToast({
title: input.language.t("toast.context.noLineSelection.title"), title: input.language.t("toast.context.noLineSelection.title"),
@@ -114,58 +129,49 @@ export const useSessionCommands = (input: {
input.addSelectionToContext(path, selectionFromLines(range)) input.addSelectionToContext(path, selectionFromLines(range))
}, },
}, }),
]) ])
const viewCommands = createMemo(() => [ const viewCommands = createMemo(() => [
{ viewCommand({
id: "terminal.toggle", id: "terminal.toggle",
title: input.language.t("command.terminal.toggle"), title: input.language.t("command.terminal.toggle"),
description: "",
category: input.language.t("command.category.view"),
keybind: "ctrl+`", keybind: "ctrl+`",
slash: "terminal", slash: "terminal",
onSelect: () => input.view().terminal.toggle(), onSelect: () => input.view().terminal.toggle(),
}, }),
{ viewCommand({
id: "review.toggle", id: "review.toggle",
title: input.language.t("command.review.toggle"), title: input.language.t("command.review.toggle"),
description: "",
category: input.language.t("command.category.view"),
keybind: "mod+shift+r", keybind: "mod+shift+r",
onSelect: () => input.view().reviewPanel.toggle(), onSelect: () => input.view().reviewPanel.toggle(),
}, }),
{ viewCommand({
id: "fileTree.toggle", id: "fileTree.toggle",
title: input.language.t("command.fileTree.toggle"), title: input.language.t("command.fileTree.toggle"),
description: "",
category: input.language.t("command.category.view"),
keybind: "mod+\\", keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(), onSelect: () => input.layout.fileTree.toggle(),
}, }),
{ viewCommand({
id: "input.focus", id: "input.focus",
title: input.language.t("command.input.focus"), title: input.language.t("command.input.focus"),
category: input.language.t("command.category.view"),
keybind: "ctrl+l", keybind: "ctrl+l",
onSelect: () => input.focusInput(), onSelect: () => input.focusInput(),
}, }),
{ terminalCommand({
id: "terminal.new", id: "terminal.new",
title: input.language.t("command.terminal.new"), title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"), description: input.language.t("command.terminal.new.description"),
category: input.language.t("command.category.terminal"),
keybind: "ctrl+alt+t", keybind: "ctrl+alt+t",
onSelect: () => { onSelect: () => {
if (input.terminal.all().length > 0) input.terminal.new() if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open() input.view().terminal.open()
}, },
}, }),
{ viewCommand({
id: "steps.toggle", id: "steps.toggle",
title: input.language.t("command.steps.toggle"), title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"), description: input.language.t("command.steps.toggle.description"),
category: input.language.t("command.category.view"),
keybind: "mod+e", keybind: "mod+e",
slash: "steps", slash: "steps",
disabled: !input.params.id, disabled: !input.params.id,
@@ -174,86 +180,78 @@ export const useSessionCommands = (input: {
if (!msg) return if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open) input.setExpanded(msg.id, (open: boolean | undefined) => !open)
}, },
}, }),
]) ])
const messageCommands = createMemo(() => [ const messageCommands = createMemo(() => [
{ sessionCommand({
id: "message.previous", id: "message.previous",
title: input.language.t("command.message.previous"), title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"), description: input.language.t("command.message.previous.description"),
category: input.language.t("command.category.session"),
keybind: "mod+arrowup", keybind: "mod+arrowup",
disabled: !input.params.id, disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1), onSelect: () => input.navigateMessageByOffset(-1),
}, }),
{ sessionCommand({
id: "message.next", id: "message.next",
title: input.language.t("command.message.next"), title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"), description: input.language.t("command.message.next.description"),
category: input.language.t("command.category.session"),
keybind: "mod+arrowdown", keybind: "mod+arrowdown",
disabled: !input.params.id, disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1), onSelect: () => input.navigateMessageByOffset(1),
}, }),
]) ])
const agentCommands = createMemo(() => [ const agentCommands = createMemo(() => [
{ modelCommand({
id: "model.choose", id: "model.choose",
title: input.language.t("command.model.choose"), title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"), description: input.language.t("command.model.choose.description"),
category: input.language.t("command.category.model"),
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => input.dialog.show(() => <DialogSelectModel />), onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}, }),
{ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
title: input.language.t("command.mcp.toggle"), title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"), description: input.language.t("command.mcp.toggle.description"),
category: input.language.t("command.category.mcp"),
keybind: "mod+;", keybind: "mod+;",
slash: "mcp", slash: "mcp",
onSelect: () => input.dialog.show(() => <DialogSelectMcp />), onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}, }),
{ agentCommand({
id: "agent.cycle", id: "agent.cycle",
title: input.language.t("command.agent.cycle"), title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"), description: input.language.t("command.agent.cycle.description"),
category: input.language.t("command.category.agent"),
keybind: "mod+.", keybind: "mod+.",
slash: "agent", slash: "agent",
onSelect: () => input.local.agent.move(1), onSelect: () => input.local.agent.move(1),
}, }),
{ agentCommand({
id: "agent.cycle.reverse", id: "agent.cycle.reverse",
title: input.language.t("command.agent.cycle.reverse"), title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"), description: input.language.t("command.agent.cycle.reverse.description"),
category: input.language.t("command.category.agent"),
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => input.local.agent.move(-1), onSelect: () => input.local.agent.move(-1),
}, }),
{ modelCommand({
id: "model.variant.cycle", id: "model.variant.cycle",
title: input.language.t("command.model.variant.cycle"), title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"), description: input.language.t("command.model.variant.cycle.description"),
category: input.language.t("command.category.model"),
keybind: "shift+mod+d", keybind: "shift+mod+d",
onSelect: () => { onSelect: () => {
input.local.model.variant.cycle() input.local.model.variant.cycle()
}, },
}, }),
]) ])
const permissionCommands = createMemo(() => [ const permissionCommands = createMemo(() => [
{ permissionsCommand({
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: title:
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) 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.disable")
: input.language.t("command.permissions.autoaccept.enable"), : input.language.t("command.permissions.autoaccept.enable"),
category: input.language.t("command.category.permissions"),
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: !input.params.id || !input.permission.permissionsEnabled(), disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => { onSelect: () => {
@@ -269,15 +267,14 @@ export const useSessionCommands = (input: {
: input.language.t("toast.permissions.autoaccept.off.description"), : input.language.t("toast.permissions.autoaccept.off.description"),
}) })
}, },
}, }),
]) ])
const sessionActionCommands = createMemo(() => [ const sessionActionCommands = createMemo(() => [
{ sessionCommand({
id: "session.undo", id: "session.undo",
title: input.language.t("command.session.undo"), title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"), description: input.language.t("command.session.undo.description"),
category: input.language.t("command.category.session"),
slash: "undo", slash: "undo",
disabled: !input.params.id || input.visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
@@ -298,12 +295,11 @@ export const useSessionCommands = (input: {
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage) input.setActiveMessage(priorMessage)
}, },
}, }),
{ sessionCommand({
id: "session.redo", id: "session.redo",
title: input.language.t("command.session.redo"), title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"), description: input.language.t("command.session.redo.description"),
category: input.language.t("command.category.session"),
slash: "redo", slash: "redo",
disabled: !input.params.id || !input.info()?.revert?.messageID, disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => { onSelect: async () => {
@@ -323,12 +319,11 @@ export const useSessionCommands = (input: {
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg) input.setActiveMessage(priorMsg)
}, },
}, }),
{ sessionCommand({
id: "session.compact", id: "session.compact",
title: input.language.t("command.session.compact"), title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"), description: input.language.t("command.session.compact.description"),
category: input.language.t("command.category.session"),
slash: "compact", slash: "compact",
disabled: !input.params.id || input.visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
@@ -348,22 +343,21 @@ export const useSessionCommands = (input: {
providerID: model.provider.id, providerID: model.provider.id,
}) })
}, },
}, }),
{ sessionCommand({
id: "session.fork", id: "session.fork",
title: input.language.t("command.session.fork"), title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"), description: input.language.t("command.session.fork.description"),
category: input.language.t("command.category.session"),
slash: "fork", slash: "fork",
disabled: !input.params.id || input.visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />), onSelect: () => input.dialog.show(() => <DialogFork />),
}, }),
]) ])
const shareCommands = createMemo(() => { const shareCommands = createMemo(() => {
if (input.sync.data.config.share === "disabled") return [] if (input.sync.data.config.share === "disabled") return []
return [ return [
{ sessionCommand({
id: "session.share", id: "session.share",
title: input.info()?.share?.url title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink") ? input.language.t("session.share.copy.copyLink")
@@ -371,7 +365,6 @@ export const useSessionCommands = (input: {
description: input.info()?.share?.url description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description") ? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"), : input.language.t("command.session.share.description"),
category: input.language.t("command.category.session"),
slash: "share", slash: "share",
disabled: !input.params.id, disabled: !input.params.id,
onSelect: async () => { onSelect: async () => {
@@ -441,12 +434,11 @@ export const useSessionCommands = (input: {
await copy(url, false) await copy(url, false)
}, },
}, }),
{ sessionCommand({
id: "session.unshare", id: "session.unshare",
title: input.language.t("command.session.unshare"), title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"), description: input.language.t("command.session.unshare.description"),
category: input.language.t("command.category.session"),
slash: "unshare", slash: "unshare",
disabled: !input.params.id || !input.info()?.share?.url, disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => { onSelect: async () => {
@@ -468,7 +460,7 @@ export const useSessionCommands = (input: {
}), }),
) )
}, },
}, }),
] ]
}) })

View File

@@ -1,55 +1,49 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd" import { useDragDropContext } from "@thisbeyond/solid-dnd"
import { JSXElement } from "solid-js"
import type { Transformer } from "@thisbeyond/solid-dnd" 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 => { export const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined if (!isDragEvent(event)) return undefined
if (!("draggable" in event)) return undefined const draggable = event.draggable
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined return typeof draggable.id === "string" ? draggable.id : undefined
} }
export const ConstrainDragXAxis = (): JSXElement => { const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({
id,
order: 100,
callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }),
})
const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => {
const context = useDragDropContext() const context = useDragDropContext()
if (!context) return <></> if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { const transformer = createTransformer(transformerId, axis)
id: "constrain-x-axis", const dispose = createRoot((dispose) => {
order: 100, onDragStart((event) => {
callback: (transform) => ({ ...transform, x: 0 }), const id = getDraggableId(event)
} if (!id) return
onDragStart((event) => { addTransformer("draggables", id, transformer)
const id = getDraggableId(event) })
if (!id) return onDragEnd((event) => {
addTransformer("draggables", id, transformer) const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return dispose
}) })
onDragEnd((event) => { onCleanup(dispose)
const id = getDraggableId(event) return null
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
} }
export const ConstrainDragYAxis = (): JSXElement => { export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis")
const context = useDragDropContext()
if (!context) return <></> export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis")
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)
})
return <></>
}