Compare commits

..

19 Commits

Author SHA1 Message Date
Brendan Allan 05778c111c Merge branch 'mobile-home-layout' into mobile-session-layout 2026-06-23 14:39:36 +08:00
Brendan Allan 43000a15d3 Merge remote-tracking branch 'origin/dev' into mobile-home-layout 2026-06-23 14:39:24 +08:00
opencode-agent[bot] 09757c605a chore: generate 2026-06-23 06:20:50 +00:00
Brendan Allan 0c4f508c50 feat(app): add server-keyed session routes (#32570) 2026-06-23 06:19:18 +00:00
opencode-agent[bot] 40db33c415 chore: generate 2026-06-23 04:57:55 +00:00
Dax Raad 9b01f15f1d test(opencode): retry Windows CLI cleanup 2026-06-23 00:56:14 -04:00
Dax Raad 81851ca6b9 test(opencode): stabilize Windows async readiness 2026-06-23 00:41:02 -04:00
Dax Raad 3c5632e110 test(opencode): stabilize Windows CLI subprocesses 2026-06-23 00:26:48 -04:00
Dax Raad 6ea3e6698a fix(opencode): scope reference readiness wait 2026-06-23 00:10:36 -04:00
Dax Raad ed75ce9ecc fix(core): prioritize reference plugin registration 2026-06-22 23:39:26 -04:00
Dax Raad af14fefc96 test(opencode): relax compaction readiness timeout 2026-06-22 23:01:05 -04:00
Dax Raad 4a710e4679 fix(core): await plugin readiness 2026-06-22 22:49:57 -04:00
Dax Raad d2c866bf70 test(core): speed up test setup 2026-06-22 22:49:57 -04:00
opencode-agent[bot] 237595e242 chore: generate 2026-06-23 02:19:31 +00:00
Aiden Cline d29f5eba92 refactor(core): remove shell description input (#32823) 2026-06-22 21:18:06 -05:00
Aiden Cline fbf889db83 fix(tui): preserve worker rejection handling (#33448)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-06-22 19:57:21 -05:00
Dax ef2357915e fix(tui): scope file autocomplete to session (#33458) 2026-06-22 20:31:36 -04:00
Brendan Allan 1a4f428ef1 fix(app): refine mobile session layout 2026-06-18 09:35:48 +02:00
Brendan Allan 45923c0293 fix(app): improve mobile home layout 2026-06-18 02:22:59 +02:00
70 changed files with 1076 additions and 694 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -21,6 +21,7 @@ const words = [
"vector", "vector",
] ]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
status: "completed", status: "completed",
input, input,
output: lorem(index * 23 + partIndex, outputLength), output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata, metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
}, },
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0 ...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = { export const fixture = {
directory, directory,
serverKey,
project: { project: {
id: projectID, id: projectID,
worktree: directory, worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable) .filter(renderable)
.map((part) => part.id), .map((part) => part.id),
), ),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
}, },
} }
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
}) })
}) })
@@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
} }
async function switchTitlebarSession(page: Page, sessionID: string, title: string) { async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
+198 -63
View File
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
@@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
@@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home")) const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=… // is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
@@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
{ preload: Session.preload }, { preload: Session.preload },
) )
const TargetSessionRoute = Object.assign(
() => {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
},
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server. // Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider> <ServerSyncProvider>{props.children}</ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</ServerKey> </ServerKey>
) )
} }
function LegacyServerLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the // Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends // server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server. // on the globally "selected" server.
function DraftServerLayout(props: ParentProps) { function TargetServerLayout(props: ParentProps) {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => { const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId const id = search.draftId
if (!id) return undefined if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
@@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
return ( return (
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell> <TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
) )
} }
function TargetDirectoryLayout(props: ParentProps) {
const params = useParams<{ serverKey?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
})
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
enabled: !!params.serverKey && !!params.id,
placeholderData: keepPreviousData,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const resolvedDirectory = createMemo(() => {
if (params.serverKey) return resolved.data?.session.directory
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}> <Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />} <ResolvedDraftRoute />
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute(props: { draftID: string }) { function ResolvedDraftRoute() {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return ( return (
<Show when={directory()} keyed> <DraftProviders>
{(dir) => ( <NewSession />
<SDKProvider directory={dir}> </DraftProviders>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
) )
} }
@@ -210,32 +293,51 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) { function SharedProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</SettingsProvider> </>
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs // each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups. // draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) { type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider> <ModelsProvider>{props.children}</ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -439,28 +541,61 @@ export function AppInterface(props: {
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <SettingsProvider>
<Dynamic <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
component={props.router ?? Router} <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
root={(routerProps) => ( <Dynamic
<TabsProvider> component={props.router ?? Router}
<ServerShell>{routerProps.children}</ServerShell> root={(routerProps) => (
</TabsProvider> <TabsProvider>
)} <ServerShell>{routerProps.children}</ServerShell>
> </TabsProvider>
<Route component={SelectedServerLayout}> )}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Routes />
<Route path="/" component={() => <Navigate href="session" />} /> </Dynamic>
<Route path="/session/:id?" component={SessionRoute} /> </Show>
</Route> </ConnectionGate>
</Route> </SettingsProvider>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
</GlobalProvider> </GlobalProvider>
</ServerProvider> </ServerProvider>
) )
} }
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={TargetServerLayout}>
<Show when={settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={NewHome} />
<Route path="/:dir" component={DirectoryLayout}>
<Route
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Route>
</>
}
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</>
)
}
@@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId const draftID = search.draftId
if (draftID) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -158,6 +159,7 @@ export function SessionHeader() {
const isV2 = settings.general.newLayoutDesigns const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search const search = settings.visibility.search
const status = settings.visibility.status const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@@ -236,6 +238,7 @@ export function SessionHeader() {
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"), reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"), reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(), reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
})) }))
@@ -518,6 +521,7 @@ type SessionHeaderV2ActionsState = {
statusLabel: string statusLabel: string
reviewLabel: string reviewLabel: string
reviewKeybind: string reviewKeybind: string
reviewVisible: boolean
reviewOpened: boolean reviewOpened: boolean
onReviewToggle: () => void onReviewToggle: () => void
} }
@@ -530,20 +534,22 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
<StatusPopoverV2 /> <StatusPopoverV2 />
</Tooltip> </Tooltip>
</Show> </Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}> <Show when={props.state.reviewVisible}>
<IconButtonV2 <TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
type="button" <IconButtonV2
variant="ghost-muted" type="button"
size="large" variant="ghost-muted"
class="!w-9 shrink-0" size="large"
state={props.state.reviewOpened ? "pressed" : undefined} class="!w-9 shrink-0"
onClick={props.state.onReviewToggle} state={props.state.reviewOpened ? "pressed" : undefined}
aria-label={props.state.reviewLabel} onClick={props.state.onReviewToggle}
aria-expanded={props.state.reviewOpened} aria-label={props.state.reviewLabel}
aria-controls="review-panel" aria-expanded={props.state.reviewOpened}
icon={<IconV2 name="sidebar-right" />} aria-controls="review-panel"
/> icon={<IconV2 name="sidebar-right" />}
</TooltipKeybind> />
</TooltipKeybind>
</Show>
</div> </div>
) )
} }
+54 -49
View File
@@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
@@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs" import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSync = useServerSync() const serverSdk = useServerSDK()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
@@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs() const tabs = useTabs()
const tabsStore = tabs.store const tabsStore = tabs.store
const tabsStoreActions = tabs const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return if (route.type === "home") return
@@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
) )
if (main) return main if (main) return main
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (s?.parentID) {
if (session?.parentID) { const parentID = s.parentID
const parentID = session.parentID
const parent = tabsStore.find( const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
) )
@@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
} }
if (route.type === "session") { if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (!s) return
if (!session) return const sessionId = s.parentID ?? s.id
const sessionId = session.parentID ?? session.id const next = { server: route.server ?? server.key, sessionId }
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const [session] = createResource(
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined),
)
return ( return (
<> <>
{divider()} {divider()}
<TabNavItem <Show when={session()}>
ref={ref} {(session) => (
href={tabHref(tab)} <TabNavItem
server={tab.server} ref={ref}
directory={decode64(tab.dirBase64)!} href={tabHref(tab)}
sessionId={tab.sessionId} server={tab.server}
onNavigate={() => { sessionId={tab.sessionId}
tabs.select(tab) session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" }) ref.scrollIntoView({ behavior: "instant" })
}} }}
onClose={() => tabsStoreActions.removeTab(i())} onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab} active={currentTab() === tab}
activeServer={tab.server === server.key} activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
/> />
)}
</Show>
</> </>
) )
}} }}
@@ -793,7 +811,6 @@ function TabNavItem(props: {
ref?: HTMLDivElement ref?: HTMLDivElement
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
directory: string
sessionId?: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
@@ -801,31 +818,19 @@ function TabNavItem(props: {
active?: boolean active?: boolean
activeServer: boolean activeServer: boolean
forceTruncate?: boolean forceTruncate?: boolean
session: Session
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn) if (conn) return global.createServerCtx(conn)
}) })
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return ( return (
<div <div
@@ -837,7 +842,7 @@ function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<Show when={session.latest}> <Show when={props.session}>
{(session) => { {(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@@ -853,7 +858,7 @@ function TabNavItem(props: {
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<ProjectTabAvatar <ProjectTabAvatar
project={project()} project={project()}
directory={props.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
activeServer={props.activeServer} activeServer={props.activeServer}
/> />
+4 -1
View File
@@ -2,12 +2,14 @@ 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"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = { export type LineComment = {
id: string id: string
@@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
@@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value return cache.get(key).value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return { return {
ready: () => session().ready(), ready: () => session().ready(),
+2 -1
View File
@@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory) const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
+13 -3
View File
@@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -79,7 +80,7 @@ export type LayoutRoute =
| { type: "home" } | { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID } return { type: "draft", draftID }
} }
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0] const dirBase64 = parts[0]
const dir = decode64(dirBase64) const dir = decode64(dirBase64)
if (!dir) return { type: "home" } if (!dir) return { type: "home" }
@@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" } if (parts[1] !== "session") return { type: "home" }
const id = parts[2] const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id } if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 } return { type: "dir-new-sesssion", dir, dirBase64 }
} }
@@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => { const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search) const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key } return { ...value, server: server.key }
}) })
@@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return
+4 -4
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
@@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
@@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = [] const empty: Notification[] = []
const currentDirectory = createMemo(() => { const currentDirectory = createMemo(() => {
return decode64(params.dir) return props.directory?.() ?? decode64(params.dir)
}) })
const currentSession = createMemo(() => params.id) const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
+4 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false if (!directory) return false
const [store] = serverSync().child(directory) const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission) return hasPermissionPromptRules(store.config.permission)
@@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept // When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return if (!directory) return
const [childStore] = serverSync().child(directory) const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission const perm = childStore.config.permission
+4 -2
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
@@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
interface PartBase { interface PartBase {
content: string content: string
@@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
) )
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())
+5 -24
View File
@@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
server: ServerConnection.Key server: ServerConnection.Key
dirBase64: string
sessionId: string sessionId: string
} }
@@ -34,16 +33,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) => export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory) return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
} }
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
if (tab.server === server.key) { navigate(href)
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
} }
const actions = { const actions = {
@@ -195,11 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store const removed = store
.filter( .filter(
(tab) => (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
tab.type === "session" &&
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
) )
.map(tabKey) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@@ -211,7 +195,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({ ? tabHref({
type: "session", type: "session",
server: server.key, server: server.key,
dirBase64: params.dir,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
@@ -224,14 +207,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === server.key && currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId) sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab || tab.type !== "session") continue if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }
+7 -5
View File
@@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk" import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { useServer } from "./server" import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title" import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const server = useServer() const serverSDK = useServerSDK()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope() const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id, scope }), () => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => { (next, prev) => {
if (!prev?.dir) return if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+22 -8
View File
@@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => { createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize. // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return if (props.draftID || props.server?.()) return
const next = sync().data.path.directory const next = sync().data.path.directory
if (!next || next === props.directory) return if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1) const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
}) })
@@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return ( return (
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={props.directory} directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>
+49 -37
View File
@@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
export default function Home() { export function NewHome() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -313,7 +303,7 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
} }
function chooseProject(conn: ServerConnection.Any) { function chooseProject(conn: ServerConnection.Any) {
@@ -339,7 +329,7 @@ function HomeDesign() {
return ( return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]"> <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
<HomeProjectColumn <HomeProjectColumn
projects={projects()} projects={projects()}
selected={state.selection} selected={state.selection}
@@ -365,7 +355,7 @@ function HomeDesign() {
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12" class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
> >
<HomeSessionSearch <HomeSessionSearch
@@ -416,7 +406,7 @@ function HomeDesign() {
record={record} record={record}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
openSession={openSession} onClick={() => openSession(record.session)}
/> />
)} )}
</For> </For>
@@ -429,6 +419,12 @@ function HomeDesign() {
</div> </div>
</ScrollView> </ScrollView>
</section> </section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div> </div>
</div> </div>
) )
@@ -453,7 +449,7 @@ function HomeProjectColumn(props: {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false }) const controller = useServerManagementController({ navigateOnAdd: false })
return ( return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}> <aside class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5"> <div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
@@ -499,28 +495,44 @@ function HomeProjectColumn(props: {
}} }}
</For> </For>
</Show> </Show>
<div class="mt-4 flex min-w-0 flex-col gap-1"> <HomeUtilityNav
<button class="mt-4 hidden lg:flex"
type="button" openSettings={props.openSettings}
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`} openHelp={props.openHelp}
onClick={props.openSettings} language={props.language}
> />
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
</aside> </aside>
) )
} }
function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: { function HomeServerRow(props: {
server: ServerConnection.Any server: ServerConnection.Any
selected: boolean selected: boolean
@@ -1024,7 +1036,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
openSession: (session: Session) => void onClick: () => void
}) { }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@@ -1033,7 +1045,7 @@ function HomeSessionRow(props: {
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)} onClick={props.onClick}
> >
<HomeSessionLeading <HomeSessionLeading
project={props.record.project} project={props.record.project}
@@ -1093,7 +1105,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0) ].filter((group) => group.sessions.length > 0)
} }
function LegacyHome() { export function LegacyHome() {
const sync = useServerSync() const sync = useServerSync()
const platform = usePlatform() const platform = usePlatform()
const pickDirectory = useDirectoryPicker() const pickDirectory = useDirectoryPicker()
+38
View File
@@ -0,0 +1,38 @@
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{props.children}
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}
+149 -170
View File
@@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) { export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => setV2Toast(false))
createEffect(() => setV2Toast(newDesign()))
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(), autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
@@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle", id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"), title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b", keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(), onSelect: () => layout.sidebar.toggle(),
}, },
{ {
@@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
}, },
] ]
if (!newDesign()) Array.from({ length: 9 }, (_, i) => {
Array.from({ length: 9 }, (_, i) => { const index = i
const index = i const number = index + 1
const number = index + 1 commands.push({
commands.push({ id: `project.${number}`,
id: `project.${number}`, category: language.t("command.category.project"),
category: language.t("command.category.project"), title: `Open Project {number}`,
title: `Open Project {number}`, keybind: `mod+${number}`,
keybind: `mod+${number}`, disabled: layout.projects.list().length <= index,
disabled: layout.projects.list().length <= index, hidden: true,
hidden: true, onSelect: () => navigateToProjectIndex(index),
onSelect: () => navigateToProjectIndex(index),
})
}) })
})
for (const [id] of availableThemeEntries()) { for (const [id] of availableThemeEntries()) {
commands.push({ commands.push({
@@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--dialog-left-margin", "--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
) )
}) })
@@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
) )
return ( return (
<Show <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
when={!newDesign()} {autoselecting() ?? ""}
fallback={ <Titlebar update={titlebarUpdate} />
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <Show when={updateVersion() !== undefined}>
{autoselecting() ?? ""} <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
<Titlebar update={titlebarUpdate} /> </Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <div class="flex-1 min-h-0 min-w-0 flex">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}> <div class="flex-1 min-h-0 relative">
{props.children} <div class="size-full relative overflow-x-hidden">
</Show> <nav
</main> aria-label={language.t("sidebar.nav.projectsAndSessions")}
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} data-component="sidebar-nav-desktop"
<HelpButton /> classList={{
<ToastRegion v2={newDesign()} /> "hidden xl:block": true,
</div> "absolute inset-y-0 left-0": true,
} "z-10": true,
> }}
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> style={{ width: `${side()}px` }}
{autoselecting() ?? ""} ref={(el) => {
<Titlebar update={titlebarUpdate} /> setState("nav", el)
<Show when={updateVersion() !== undefined}> }}
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} /> onMouseEnter={() => {
</Show> disarm()
<div class="flex-1 min-h-0 min-w-0 flex"> }}
<div class="flex-1 min-h-0 relative"> onMouseLeave={() => {
<div class="size-full relative overflow-x-hidden"> aim.reset()
<nav if (!sidebarHovering()) return
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm() arm()
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav> </nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<Show when={layout.sidebar.opened()}>
<div <div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: "calc(4rem + 12px)" }} style={{ left: `${side()}px` }}
/> onPointerDown={() => setState("sizing", true)}
>
<div class="xl:hidden"> <ResizeHandle
<div direction="horizontal"
classList={{ size={layout.sidebar.width()}
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true, min={244}
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), onResize={(w) => {
}} setState("sizing", true)
onClick={(e) => { if (sizet !== undefined) clearTimeout(sizet)
if (e.target === e.currentTarget) layout.mobileSidebar.hide() sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}} }}
/> />
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div> </div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div <div
classList={{ classList={{
"absolute inset-0": true, "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true, "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"z-20": true, "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}} }}
style={{ onClick={(e) => {
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem", if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}} }}
> />
<main <nav
classList={{ aria-label={language.t("sidebar.nav.projectsAndSessions")}
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, data-component="sidebar-nav-mobile"
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{ classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true, "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(), "translate-x-0": layout.mobileSidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
onMouseMove={disarm} onClick={(e) => e.stopPropagation()}
onMouseEnter={() => { >
disarm() {sidebarContent(true)}
aim.reset() </nav>
}} </div>
onPointerDown={disarm}
onMouseLeave={() => { <div
arm() classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}} }}
> >
<Show when={peekProject()}> <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
<SidebarPanel project={peekProject} merged={false} /> {props.children}
</Show> </Show>
</div> </main>
</div>
<div <div
classList={{ classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true, "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(), "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(), "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true, "transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${panel()}px)` }} onMouseMove={disarm}
> onMouseEnter={() => {
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} /> disarm()
</div> aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div> </div>
</div> </div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<HelpButton /> {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<ToastRegion v2={newDesign()} />
</div> </div>
</Show> <HelpButton />
<ToastRegion v2={false} />
</div>
) )
} }
+38 -35
View File
@@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal() const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs() layout.handoff.clearTabs()
return return
} }
if (pending.scope !== server.scope()) return if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return if (pending.id !== id) return
layout.handoff.clearTabs() layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs() const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return if (from.all.length === 0 && !from.active) return
@@ -247,7 +245,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id }), () => ({ dir: sdk().directory, id: params.id }),
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
@@ -575,7 +573,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.dir, () => sdk().directory,
(dir) => { (dir) => {
if (!dir) return if (!dir) return
setStore("newSessionWorktree", "main") setStore("newSessionWorktree", "main")
@@ -1570,40 +1568,42 @@ export default function Page() {
/> />
) )
const mobileTabs = (compact = false) => (
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List class={compact ? "!h-9" : undefined}>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
)
return ( return (
<div class="relative size-full overflow-hidden flex flex-col"> <div class="relative size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""} {sessionSync() ?? ""}
<SessionHeader /> <SessionHeader />
<div <div
class="flex-1 min-h-0 flex flex-col md:flex-row " class="flex-1 min-h-0 flex flex-col md:flex-row"
classList={{ classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(), "gap-2 p-2": settings.general.newLayoutDesigns(),
}} }}
> >
<Show when={!isDesktop() && !!params.id}> <Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
<div <div
classList={{ classList={{
@@ -1622,6 +1622,9 @@ export default function Page() {
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
> >
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id && mobileChanges()}> <Match when={params.id && mobileChanges()}>
@@ -1629,8 +1632,8 @@ export default function Page() {
{reviewContent({ {reviewContent({
diffStyle: "unified", diffStyle: "unified",
classes: { classes: {
root: "pb-8", root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4", header: "px-4 !h-16 !pb-4",
container: "px-4", container: "px-4",
}, },
loadingClass: "px-4 py-4 text-text-weak", loadingClass: "px-4 py-4 text-text-weak",
@@ -1686,7 +1689,7 @@ export default function Page() {
</Switch> </Switch>
</div> </div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show> <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
</div> </div>
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
@@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
const openParent = () => { const openParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${route.params.dir}/session/${id}`) navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
} }
createEffect(() => { createEffect(() => {
@@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => { export const useSessionKey = () => {
const params = useParams() const params = useParams()
const server = useServer() const sdk = useSDK()
const scope = createMemo(() => server.scope()) const serverSDK = useServerSDK()
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) const scope = createMemo(() => serverSDK().scope)
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey } return { params, sessionKey, workspaceKey }
} }
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240] const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout() const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const size = createSizing() const size = createSizing()
@@ -122,7 +124,7 @@ export function TerminalPanel() {
}) })
createEffect(() => { createEffect(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return if (!dir) return
if (!terminal.ready()) return if (!terminal.ready()) return
language.locale() language.locale()
@@ -140,7 +142,7 @@ export function TerminalPanel() {
}) })
const handoff = createMemo(() => { const handoff = createMemo(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return [] if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? [] return getTerminalHandoff(workspaceKey()) ?? []
}) })
@@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -260,6 +262,7 @@ export function MessageTimeline(props: {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { params, sessionKey } = useSessionKey()
@@ -757,12 +760,18 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) { if (parentID) {
navigate(`/${params.dir}/session/${parentID}`) navigate(href(parentID))
return return
} }
if (nextSessionID) { if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`) navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return return
} }
navigate(`/${params.dir}/session`) navigate(`/${params.dir}/session`)
@@ -864,7 +873,9 @@ export function MessageTimeline(props: {
const navigateParent = () => { const navigateParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${params.dir}/session/${id}`) navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {
@@ -1285,7 +1296,9 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true, "pr-3": true,
"pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
@@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"), title: language.t("command.session.new"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}), }),
sessionCommand({ sessionCommand({
id: "session.undo", id: "session.undo",
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}
+50 -3
View File
@@ -1,6 +1,6 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
@@ -29,6 +29,7 @@ export const Event = {
export interface Interface { export interface Interface {
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void> readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
readonly wait: (id: ID) => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@@ -41,13 +42,18 @@ export const layer = Layer.effect(
const scope = yield* Scope.make() const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>() const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>() const loading = new Set<ID>()
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
const failures = new Map<ID, Exit.Exit<void, never>>()
let host: Parameters<Plugin["effect"]>[0] let host: Parameters<Plugin["effect"]>[0]
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
yield* locks.withLock(id)( yield* locks.withLock(id)(
Effect.sync(() => loading.add(id)).pipe( Effect.sync(() => {
loading.add(id)
failures.delete(id)
}).pipe(
Effect.andThen( Effect.andThen(
State.batch( State.batch(
Effect.gen(function* () { Effect.gen(function* () {
@@ -61,11 +67,22 @@ export const layer = Layer.effect(
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
) )
active.set(id, child)
yield* events.publish(Event.Added, { id }) yield* events.publish(Event.Added, { id })
active.set(id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
discard: true,
})
waiters.delete(id)
}), }),
), ),
), ),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit)
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
}),
Effect.ensuring(Effect.sync(() => loading.delete(id))), Effect.ensuring(Effect.sync(() => loading.delete(id))),
), ),
) )
@@ -79,12 +96,41 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const current = active.get(id) const current = active.get(id)
active.delete(id) active.delete(id)
failures.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}), }),
), ),
) )
}) })
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
const waiter = yield* Deferred.make<void>()
const pending = yield* locks.withLock(id)(
Effect.sync(() => {
if (active.has(id)) return false
const failure = failures.get(id)
if (failure) return failure
const current = waiters.get(id) ?? new Set()
current.add(waiter)
waiters.set(id, current)
return true
}),
)
if (!pending) return
if (typeof pending !== "boolean") return yield* pending
yield* Deferred.await(waiter).pipe(
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter)
if (current?.size === 0) waiters.delete(id)
}),
),
),
)
})
yield* Effect.addFinalizer((exit) => yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () { Effect.gen(function* () {
active.clear() active.clear()
@@ -95,6 +141,7 @@ export const layer = Layer.effect(
const service = Service.of({ const service = Service.of({
add, add,
remove, remove,
wait,
}) })
host = yield* PluginHost.make(service) host = yield* PluginHost.make(service)
return service return service
+1 -1
View File
@@ -98,6 +98,7 @@ export const locationLayer = Layer.effectDiscard(
} }
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin) yield* add(SkillPlugin.Plugin)
@@ -106,7 +107,6 @@ export const locationLayer = Layer.effectDiscard(
yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item) for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigExternalPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
-3
View File
@@ -28,9 +28,6 @@ export const Input = Schema.Struct({
.annotate({ .annotate({
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
}), }),
description: Schema.String.pipe(Schema.optional).annotate({
description: "Concise description of the command's purpose",
}),
}) })
const Output = Schema.Struct({ const Output = Schema.Struct({
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Exit, Fiber } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -9,6 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
describe("PluginV2", () => { describe("PluginV2", () => {
it.effect("waits for a plugin and returns immediately once active", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("waited")
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
yield* plugins.add(id, () => Effect.void)
yield* Fiber.join(waiting)
yield* plugins.wait(id)
}),
)
it.effect("propagates plugin activation defects to waiters", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("failed")
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
const pending = yield* Fiber.join(waiting)
const later = yield* plugins.wait(id).pipe(Effect.exit)
expect(Exit.isFailure(added)).toBe(true)
expect(Exit.isFailure(pending)).toBe(true)
expect(Exit.isFailure(later)).toBe(true)
}),
)
it.effect("adds, replaces, and removes plugins", () => it.effect("adds, replaces, and removes plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
+4
View File
@@ -1 +1,5 @@
import path from "path"
process.env.OPENCODE_DB = ":memory:" process.env.OPENCODE_DB = ":memory:"
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json")
process.env.OPENCODE_DISABLE_MODELS_FETCH = "true"
+1 -1
View File
@@ -144,7 +144,7 @@ describe("AppProcess", () => {
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)` const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
return Effect.gen(function* () { return Effect.gen(function* () {
const svc = yield* AppProcess.Service const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" })) const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" }))
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
expect(yield* waitForFile(ready)).toMatch(/^\d+$/) expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
expect(yield* waitForFile(settled)).toBe("settled") expect(yield* waitForFile(settled)).toBe("settled")
+2 -3
View File
@@ -134,10 +134,9 @@ describe("BashTool", () => {
const definitions = yield* toolDefinitions(registry) const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect( expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: { output: {
structured: { structured: {
+1 -1
View File
@@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
// For shell tools, surface the actual command as the title so it stays visible // For shell tools, surface the actual command as the title so it stays visible
// before output lands; non-shell tools keep their model-provided title. // before output lands; non-shell tools keep their model-provided title.
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName
return fallback || toolName return fallback || toolName
} }
+7 -3
View File
@@ -30,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
export const Info = Schema.Struct({ export const Info = Schema.Struct({
name: Schema.String, name: Schema.String,
@@ -98,9 +99,12 @@ export const layer = Layer.effect(
Effect.fn("Agent.state")(function* (ctx) { Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const skillDirs = yield* skill.dirs() const skillDirs = yield* skill.dirs()
const referenceDirs = yield* Effect.gen(function* () { const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) ? yield* Effect.gen(function* () {
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
: []
const whitelistedDirs = [ const whitelistedDirs = [
Truncate.GLOB, Truncate.GLOB,
path.join(Global.Path.tmp, "*"), path.join(Global.Path.tmp, "*"),
+6 -9
View File
@@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
function scrollBashStart(p: ToolProps<typeof BashTool>): string { function scrollBashStart(p: ToolProps<typeof BashTool>): string {
const cmd = p.input.command ?? "" const cmd = p.input.command ?? ""
const desc = p.input.description || "Shell"
const wd = p.input.workdir ?? "" const wd = p.input.workdir ?? ""
const dir = wd && wd !== "." ? toolPath(wd) : "" const formatted = wd && wd !== "." ? toolPath(wd) : ""
if (cmd && desc === "Shell" && !dir) { const dir = formatted === "." ? "" : formatted
if (cmd && !dir) {
return `$ ${cmd}` return `$ ${cmd}`
} }
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
if (!cmd) { if (!cmd) {
return `# ${title}` return dir ? `# Running in ${dir}` : ""
} }
return `# ${title}\n$ ${cmd}` return `# Running in ${dir}\n$ ${cmd}`
} }
function scrollBashProgress(p: ToolProps<typeof BashTool>): string { function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
@@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
} }
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo { function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
const title = p.input.description || "Shell command"
const cmd = p.input.command || "" const cmd = p.input.command || ""
return { return {
icon: "#", icon: "#",
title, title: "Shell command",
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
} }
} }
+9
View File
@@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy
Heap.start() Heap.start()
const onUnhandledRejection = (_error: unknown) => {}
const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
// Subscribe to global events and forward them via RPC // Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => { GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event) Rpc.emit("global.event", event)
@@ -65,6 +72,8 @@ export const rpc = {
async shutdown() { async shutdown() {
await InstanceRuntime.disposeAllInstances() await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true) if (server) await server.stop(true)
process.off("unhandledRejection", onUnhandledRejection)
process.off("uncaughtException", onUncaughtException)
}, },
} }
+2 -2
View File
@@ -542,7 +542,7 @@ export const layer = Layer.effect(
time: { ...part.state.time, end: completed }, time: { ...part.state.time, end: completed },
input: part.state.input, input: part.state.input,
title: "", title: "",
metadata: { output, description: "" }, metadata: { output },
output, output,
} }
yield* sessions.updatePart(part) yield* sessions.updatePart(part)
@@ -569,7 +569,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
output += chunk output += chunk
if (part.state.status === "running") { if (part.state.status === "running") {
part.state.metadata = { output, description: "" } part.state.metadata = { output }
yield* sessions.updatePart(part) yield* sessions.updatePart(part)
} }
}), }),
+2 -14
View File
@@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
return tree return tree
}) })
const ask = Effect.fn("ShellTool.ask")(function* ( const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {
ctx: Tool.Context,
scan: Scan,
input: { command: string; description: string },
) {
if (scan.dirs.size > 0) { if (scan.dirs.size > 0) {
const directories = Array.from(scan.dirs) const directories = Array.from(scan.dirs)
const globs = directories.map((dir) => { const globs = directories.map((dir) => {
@@ -277,7 +273,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
always: globs, always: globs,
metadata: { metadata: {
command: input.command, command: input.command,
description: input.description,
directories, directories,
patterns: globs, patterns: globs,
}, },
@@ -291,7 +286,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
always: Array.from(scan.always), always: Array.from(scan.always),
metadata: { metadata: {
command: input.command, command: input.command,
description: input.description,
}, },
}) })
}) })
@@ -438,7 +432,6 @@ export const ShellTool = Tool.define(
cwd: string cwd: string
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
timeout: number timeout: number
description: string
}, },
ctx: Tool.Context, ctx: Tool.Context,
) { ) {
@@ -482,7 +475,6 @@ export const ShellTool = Tool.define(
yield* ctx.metadata({ yield* ctx.metadata({
metadata: { metadata: {
output: "", output: "",
description: input.description,
}, },
}) })
@@ -523,7 +515,6 @@ export const ShellTool = Tool.define(
ctx.metadata({ ctx.metadata({
metadata: { metadata: {
output: last, output: last,
description: input.description,
}, },
}), }),
), ),
@@ -534,7 +525,6 @@ export const ShellTool = Tool.define(
return ctx.metadata({ return ctx.metadata({
metadata: { metadata: {
output: last, output: last,
description: input.description,
}, },
}) })
}), }),
@@ -593,11 +583,10 @@ export const ShellTool = Tool.define(
output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>" output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
} }
return { return {
title: input.description, title: input.command,
metadata: { metadata: {
output: last || preview(output), output: last || preview(output),
exit: code, exit: code,
description: input.description,
truncated: cut, truncated: cut,
...(cut && file ? { outputPath: file } : {}), ...(cut && file ? { outputPath: file } : {}),
}, },
@@ -646,7 +635,6 @@ export const ShellTool = Tool.define(
cwd, cwd,
env: yield* shellEnv(ctx, cwd), env: yield* shellEnv(ctx, cwd),
timeout, timeout,
description: params.description,
}, },
ctx, ctx,
) )
+3 -17
View File
@@ -7,30 +7,22 @@ import { ShellID } from "./id"
const PS = new Set(["powershell", "pwsh"]) const PS = new Set(["powershell", "pwsh"])
const CMD = new Set(["cmd"]) const CMD = new Set(["cmd"])
const descriptions = {
bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
powershell:
'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp',
cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp',
}
export type Limits = { export type Limits = {
maxLines: number maxLines: number
maxBytes: number maxBytes: number
} }
export function parameterSchema(description: string) { export function parameterSchema() {
return Schema.Struct({ return Schema.Struct({
command: Schema.String.annotate({ description: "The command to execute" }), command: Schema.String.annotate({ description: "The command to execute" }),
timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
workdir: Schema.optional(Schema.String).annotate({ workdir: Schema.optional(Schema.String).annotate({
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
}), }),
description: Schema.String.annotate({ description }),
}) })
} }
export const Parameters = parameterSchema(descriptions.bash) export const Parameters = parameterSchema()
export type Parameters = Schema.Schema.Type<typeof Parameters> export type Parameters = Schema.Schema.Type<typeof Parameters>
function renderPrompt(template: string, values: Record<string, string>) { function renderPrompt(template: string, values: Record<string, string>) {
@@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -155,7 +146,6 @@ Before executing the command, please follow these steps:
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -205,7 +195,6 @@ Before executing the command, please follow these steps:
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
@@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
gitCommandRestriction: "git commands", gitCommandRestriction: "git commands",
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`,
parameterDescription: descriptions.cmd,
} }
} }
if (isPowerShell) { if (isPowerShell) {
@@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
## Summary ## Summary
- <1-3 bullet points> - <1-3 bullet points>
'@`, '@`,
parameterDescription: descriptions.powershell,
} }
} }
return { return {
@@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary ## Summary
<1-3 bullet points>`, <1-3 bullet points>`,
parameterDescription: descriptions.bash,
} }
} }
@@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits,
createPrInstruction: selected.createPrInstruction, createPrInstruction: selected.createPrInstruction,
createPrExample: selected.createPrExample, createPrExample: selected.createPrExample,
}), }),
parameters: parameterSchema(selected.parameterDescription), parameters: parameterSchema(),
} }
} }
@@ -13,7 +13,6 @@
// version (changes per release), so we'd snapshot a moving target. // version (changes per release), so we'd snapshot a moving target.
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { EOL } from "os"
import { cliIt } from "../../lib/cli-process" import { cliIt } from "../../lib/cli-process"
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
@@ -101,7 +100,7 @@ describe("opencode CLI help-text snapshots", () => {
Effect.gen(function* () { Effect.gen(function* () {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0) expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith(EOL)).toBe(true) expect(topLevel.stderr.endsWith("\n")).toBe(true)
expect(topLevel.stderr).toContain("--mini") expect(topLevel.stderr).toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant") expect(topLevel.stderr).not.toContain("--variant")
@@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => {
} }
}) })
test("omits the current directory from bash titles", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "pwd",
workdir: process.cwd(),
},
time: { start: 1 },
},
}),
)
const commits = claim(out.renderer)
try {
expect(render(commits)).toContain("$ pwd")
expect(render(commits)).not.toContain("Running in .")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders completed bash output with one blank line after the command and before the next group", async () => { test("renders completed bash output with one blank line after the command and before the next group", async () => {
const out = await setup() const out = await setup()
@@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be
input: { input: {
command: "git status", command: "git status",
workdir: "/tmp/demo", workdir: "/tmp/demo",
description: "Show git status",
}, },
time: { start: 1 }, time: { start: 1 },
}, },
@@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be
input: { input: {
command: "git status", command: "git status",
workdir: "/tmp/demo", workdir: "/tmp/demo",
description: "Show git status",
}, },
time: { start: 1, end: 2 }, time: { start: 1, end: 2 },
}, },
@@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be
take() take()
const output = lines.join("\n") const output = lines.join("\n")
expect(output).toContain("# Running in /tmp/demo\n$ git status")
expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("$ git status\n\nOn branch demo")
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
@@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
input: { input: {
command: "pwd; ls -la", command: "pwd; ls -la",
workdir: "/tmp/demo", workdir: "/tmp/demo",
description: "Lists current directory files",
}, },
time: { start: 1 }, time: { start: 1 },
}, },
@@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
input: { input: {
command: "pwd; ls -la", command: "pwd; ls -la",
workdir: "/tmp/demo", workdir: "/tmp/demo",
description: "Lists current directory files",
}, },
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
title: "pwd; ls -la", title: "pwd; ls -la",
@@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header
input: { input: {
command: "ls", command: "ls",
workdir: "src/cli/cmd/run", workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
}, },
time: { start: 1 }, time: { start: 1 },
}, },
@@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header
input: { input: {
command: "ls", command: "ls",
workdir: "src/cli/cmd/run", workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
}, },
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
title: "ls", title: "ls",
@@ -435,7 +435,6 @@ describe("run session data", () => {
title: "", title: "",
metadata: { metadata: {
output: "/tmp/demo\n", output: "/tmp/demo\n",
description: "",
}, },
time: { start: 1, end: 2 }, time: { start: 1, end: 2 },
}, },
@@ -490,7 +489,6 @@ describe("run session data", () => {
title: "", title: "",
metadata: { metadata: {
output: "/tmp/demo\n", output: "/tmp/demo\n",
description: "",
}, },
time: { start: 1, end: 2 }, time: { start: 1, end: 2 },
}, },
@@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu
title: "", title: "",
metadata: { metadata: {
output: "account.ts\n", output: "account.ts\n",
description: "",
}, },
time: { time: {
start: 200, start: 200,
+13 -5
View File
@@ -20,7 +20,7 @@
import { test, type TestOptions } from "bun:test" import { test, type TestOptions } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import path from "node:path" import path from "node:path"
@@ -192,9 +192,12 @@ export function withCliFixture<A, E>(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const appProc = yield* AppProcess.Service const appProc = yield* AppProcess.Service
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" })
// cleanup — replaces the old mkdir + addFinalizer pair. yield* Effect.addFinalizer(() =>
const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) fs
.remove(home, { recursive: true })
.pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore),
)
const configJson = JSON.stringify(testProviderConfig(llm.url)) const configJson = JSON.stringify(testProviderConfig(llm.url))
const env = isolatedEnv(home, configJson) const env = isolatedEnv(home, configJson)
@@ -517,5 +520,10 @@ export const cliIt = {
name: string, name: string,
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>, body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
opts?: number | TestOptions, opts?: number | TestOptions,
) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), ) =>
(process.platform === "win32" ? test : test.concurrent)(
name,
() => Effect.runPromise(Effect.scoped(withCliFixture(body))),
opts,
),
} }
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect" import { Context, Effect } from "effect"
import path from "path" import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { pollWithTimeout } from "../lib/effect"
const context = Context.empty() as Context.Context<unknown> const context = Context.empty() as Context.Context<unknown>
@@ -55,17 +56,26 @@ describe("file HttpApi", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "needle") await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
const [text, files, symbols] = await Promise.all([ const [text, symbols] = await Promise.all([
request(FilePaths.findText, tmp.path, { pattern: "needle" }), request(FilePaths.findText, tmp.path, { pattern: "needle" }),
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
request(FilePaths.findSymbol, tmp.path, { query: "hello" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
]) ])
const files = await Effect.runPromise(
pollWithTimeout(
Effect.promise(async () => {
const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" })
const body = await response.json()
return body.includes("hello.txt") ? { response, body } : undefined
}),
"file search index was not ready",
),
)
expect(text.status).toBe(200) expect(text.status).toBe(200)
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
expect(files.status).toBe(200) expect(files.response.status).toBe(200)
expect(await files.json()).toContain("hello.txt") expect(files.body).toContain("hello.txt")
expect(symbols.status).toBe(200) expect(symbols.status).toBe(200)
expect(await symbols.json()).toEqual([]) expect(await symbols.json()).toEqual([])
@@ -4,6 +4,8 @@ import { Server } from "../../src/server/server"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
import { pollWithTimeout } from "../lib/effect"
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
@@ -24,12 +26,19 @@ describe("reference HttpApi", () => {
}, },
}) })
const response = await Server.Default().app.request("/api/reference", { const body = await Effect.runPromise(
headers: { "x-opencode-directory": tmp.path }, pollWithTimeout(
}) Effect.promise(async () => {
const response = await Server.Default().app.request("/api/reference", {
expect(response.status).toBe(200) headers: { "x-opencode-directory": tmp.path },
const body = await response.json() })
expect(response.status).toBe(200)
const body = await response.json()
return body.data.length === 0 ? undefined : body
}),
"references were not loaded",
),
)
expect(body).toMatchObject({ location: { directory: tmp.path } }) expect(body).toMatchObject({ location: { directory: tmp.path } })
expect(body.data).toEqual([ expect(body.data).toEqual([
{ {
@@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server"
import path from "path" import path from "path"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider" import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@@ -389,7 +389,12 @@ describe("HttpApi SDK", () => {
workspaceID, workspaceID,
onRequest: (value) => (request = value), onRequest: (value) => (request = value),
}) })
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) const found = yield* pollWithTimeout(
call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe(
Effect.map((result) => (result.data?.data.length ? result : undefined)),
),
"SDK file search index was not ready",
)
const url = new URL(request!.url) const url = new URL(request!.url)
expect(found.response.status).toBe(200) expect(found.response.status).toBe(200)
@@ -1250,7 +1250,7 @@ describe("session.compaction.process", () => {
}) })
.pipe(Effect.forkChild) .pipe(Effect.forkChild)
yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds"))
const start = Date.now() const start = Date.now()
yield* Fiber.interrupt(fiber) yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
@@ -1263,6 +1263,7 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ llm: stub.layer })) }).pipe(withCompaction({ llm: stub.layer }))
}, },
{ git: true }, { git: true },
{ timeout: 10_000 },
) )
itCompaction.instance( itCompaction.instance(
@@ -1630,7 +1630,7 @@ it.instance(
expect(yield* llm.calls).toBe(1) expect(yield* llm.calls).toBe(1)
}), }),
{ git: true }, { git: true },
3_000, 10_000,
) )
it.instance( it.instance(
@@ -1811,7 +1811,6 @@ unix(
yield* llm.tool("bash", { yield* llm.tool("bash", {
command: command:
'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30',
description: "Print many lines",
timeout: 30_000, timeout: 30_000,
workdir: path.resolve(dir), workdir: path.resolve(dir),
}) })
@@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
command, command,
description: "create test file",
}) })
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
@@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
"description": "The command to execute", "description": "The command to execute",
"type": "string", "type": "string",
}, },
"description": {
"description":
"Clear, concise description of what this command does in 5-10 words. Examples:
Input: ls
Output: Lists files in current directory
Input: git status
Output: Shows working tree status
Input: npm install
Output: Installs package dependencies
Input: mkdir foo
Output: Creates directory 'foo'"
,
"type": "string",
},
"timeout": { "timeout": {
"description": "Optional timeout in milliseconds", "description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
@@ -55,7 +38,6 @@ Output: Creates directory 'foo'"
}, },
"required": [ "required": [
"command", "command",
"description",
], ],
"type": "object", "type": "object",
} }
@@ -106,19 +106,16 @@ describe("tool parameters", () => {
}) })
describe("shell", () => { describe("shell", () => {
test("accepts minimum: command + description", () => { test("accepts command", () => {
expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" })
}) })
test("accepts optional timeout + workdir", () => { test("accepts optional timeout + workdir", () => {
const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" })
expect(parsed.timeout).toBe(5000) expect(parsed.timeout).toBe(5000)
expect(parsed.workdir).toBe("/tmp") expect(parsed.workdir).toBe("/tmp")
}) })
test("rejects missing description", () => {
expect(accepts(Shell, { command: "ls" })).toBe(false)
})
test("rejects missing command", () => { test("rejects missing command", () => {
expect(accepts(Shell, { description: "list" })).toBe(false) expect(accepts(Shell, {})).toBe(false)
}) })
}) })
+4 -48
View File
@@ -182,7 +182,6 @@ describe("tool.shell", () => {
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* run({ const result = yield* run({
command: "echo test", command: "echo test",
description: "Echo test message",
}) })
expect(result.metadata.exit).toBe(0) expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test") expect(result.metadata.output).toContain("test")
@@ -204,7 +203,6 @@ describe("tool.shell", () => {
const result = yield* bash.execute( const result = yield* bash.execute(
{ {
command: "echo fallback", command: "echo fallback",
description: "Echo fallback text",
}, },
ctx, ctx,
) )
@@ -227,7 +225,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "echo hello", command: "echo hello",
description: "Echo hello",
}, },
capture(requests), capture(requests),
) )
@@ -249,7 +246,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "echo foo && echo bar", command: "echo foo && echo bar",
description: "Echo twice",
}, },
capture(requests), capture(requests),
) )
@@ -273,7 +269,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "Write-Host foo; if ($?) { Write-Host bar }", command: "Write-Host foo; if ($?) { Write-Host bar }",
description: "Check PowerShell conditional",
}, },
capture(requests), capture(requests),
) )
@@ -303,7 +298,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: "Remove-Item -Recurse tmp", command: "Remove-Item -Recurse tmp",
description: "Remove a temp directory",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -331,7 +325,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: `cat ${file}`, command: `cat ${file}`,
description: "Read wildcard path",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -359,7 +352,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: `echo $(cat "${file}")`, command: `echo $(cat "${file}")`,
description: "Read nested bash file",
}, },
capture(requests), capture(requests),
) )
@@ -389,7 +381,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
description: "Copy Windows ini",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -415,7 +406,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: `Write-Output $(Get-Content ${file})`, command: `Write-Output $(Get-Content ${file})`,
description: "Read nested PowerShell file",
}, },
capture(requests), capture(requests),
) )
@@ -446,7 +436,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: 'Get-Content "C:../outside.txt"', command: 'Get-Content "C:../outside.txt"',
description: "Read drive-relative file",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -474,7 +463,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: 'Get-Content "$HOME/.ssh/config"', command: 'Get-Content "$HOME/.ssh/config"',
description: "Read home config",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -503,7 +491,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: 'Get-Content "$PWD/../outside.txt"', command: 'Get-Content "$PWD/../outside.txt"',
description: "Read pwd-relative file",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -531,7 +518,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: 'Get-Content "$PSHOME/outside.txt"', command: 'Get-Content "$PSHOME/outside.txt"',
description: "Read pshome file",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -567,7 +553,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
description: "Read Windows ini with missing env",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -598,7 +583,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "Get-Content $env:WINDIR/win.ini", command: "Get-Content $env:WINDIR/win.ini",
description: "Read Windows ini from env",
}, },
capture(requests), capture(requests),
) )
@@ -626,7 +610,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
description: "Read Windows ini from FileSystem provider",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -655,7 +638,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: "Get-Content ${env:WINDIR}/win.ini", command: "Get-Content ${env:WINDIR}/win.ini",
description: "Read Windows ini from braced env",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -682,7 +664,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "Set-Location C:/Windows", command: "Set-Location C:/Windows",
description: "Change location",
}, },
capture(requests), capture(requests),
) )
@@ -710,7 +691,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "Write-Output ('a' * 3)", command: "Write-Output ('a' * 3)",
description: "Write repeated text",
}, },
capture(requests), capture(requests),
) )
@@ -736,7 +716,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
description: "Read Windows ini with cmd",
}, },
capture(requests), capture(requests),
) )
@@ -761,7 +740,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: "cd ../", command: "cd ../",
description: "Change to parent directory",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -786,7 +764,6 @@ describe("tool.shell permissions", () => {
{ {
command: "echo ok", command: "echo ok",
workdir: os.tmpdir(), workdir: os.tmpdir(),
description: "Echo from temp dir",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -817,7 +794,6 @@ describe("tool.shell permissions", () => {
{ {
command: "echo ok", command: "echo ok",
workdir: dir, workdir: dir,
description: "Echo from external dir",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -850,7 +826,6 @@ describe("tool.shell permissions", () => {
{ {
command: "echo ok", command: "echo ok",
workdir: "/tmp", workdir: "/tmp",
description: "Echo from Git Bash tmp",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -878,7 +853,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: "cat /tmp/opencode-does-not-exist", command: "cat /tmp/opencode-does-not-exist",
description: "Read Git Bash tmp file",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -910,7 +884,6 @@ describe("tool.shell permissions", () => {
yield* fail( yield* fail(
{ {
command: `cat ${filepath}`, command: `cat ${filepath}`,
description: "Read external file",
}, },
capture(requests, err), capture(requests, err),
), ),
@@ -922,7 +895,6 @@ describe("tool.shell permissions", () => {
expect(extDirReq!.always).toContain(expected) expect(extDirReq!.always).toContain(expected)
expect(extDirReq!.metadata).toMatchObject({ expect(extDirReq!.metadata).toMatchObject({
command: `cat ${filepath}`, command: `cat ${filepath}`,
description: "Read external file",
directories: [outerTmp], directories: [outerTmp],
patterns: [expected], patterns: [expected],
}) })
@@ -942,7 +914,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: `rm -rf ${path.join(tmp, "nested")}`, command: `rm -rf ${path.join(tmp, "nested")}`,
description: "Remove nested dir",
}, },
capture(requests), capture(requests),
) )
@@ -963,7 +934,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "git log --oneline -5", command: "git log --oneline -5",
description: "Git log",
}, },
capture(requests), capture(requests),
) )
@@ -985,7 +955,6 @@ describe("tool.shell permissions", () => {
yield* run( yield* run(
{ {
command: "cd .", command: "cd .",
description: "Stay in current directory",
}, },
capture(requests), capture(requests),
) )
@@ -1004,12 +973,9 @@ describe("tool.shell permissions", () => {
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
expect( expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({
yield* fail( message: err.message,
{ command: "echo test > output.txt", description: "Redirect test output" }, })
capture(requests, err),
),
).toMatchObject({ message: err.message })
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo test > output.txt") expect(bashReq!.patterns).toContain("echo test > output.txt")
@@ -1025,7 +991,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run({ command: "ls -la", description: "List" }, capture(requests)) yield* run({ command: "ls -la" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
expect(bashReq!.always[0]).toBe("ls *") expect(bashReq!.always[0]).toBe("ls *")
@@ -1047,7 +1013,6 @@ describe("tool.shell abort", () => {
const res = yield* run( const res = yield* run(
{ {
command: `echo before && sleep 30`, command: `echo before && sleep 30`,
description: "Long running command",
}, },
{ {
...ctx, ...ctx,
@@ -1078,7 +1043,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* run({ const result = yield* run({
command: `sleep 60`, command: `sleep 60`,
description: "Timeout test",
timeout: 500, timeout: 500,
}) })
expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("shell tool terminated command after exceeding timeout")
@@ -1099,7 +1063,6 @@ describe("tool.shell abort", () => {
const result = yield* tool.execute( const result = yield* tool.execute(
{ {
command: `sleep 60`, command: `sleep 60`,
description: "Default timeout test",
}, },
ctx, ctx,
) )
@@ -1116,7 +1079,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* run({ const result = yield* run({
command: `echo stdout_msg && echo stderr_msg >&2`, command: `echo stdout_msg && echo stderr_msg >&2`,
description: "Stderr test",
}) })
expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stdout_msg")
expect(result.output).toContain("stderr_msg") expect(result.output).toContain("stderr_msg")
@@ -1132,7 +1094,6 @@ describe("tool.shell abort", () => {
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* run({ const result = yield* run({
command: `exit 42`, command: `exit 42`,
description: "Non-zero exit",
}) })
expect(result.metadata.exit).toBe(42) expect(result.metadata.exit).toBe(42)
}), }),
@@ -1147,7 +1108,6 @@ describe("tool.shell abort", () => {
const result = yield* run( const result = yield* run(
{ {
command: `echo first && sleep 0.1 && echo second`, command: `echo first && sleep 0.1 && echo second`,
description: "Streaming test",
}, },
{ {
...ctx, ...ctx,
@@ -1174,7 +1134,6 @@ describe("tool.shell truncation", () => {
const lineCount = Truncate.MAX_LINES + 500 const lineCount = Truncate.MAX_LINES + 500
const result = yield* run({ const result = yield* run({
command: fill("lines", lineCount), command: fill("lines", lineCount),
description: "Generate lines exceeding limit",
}) })
mustTruncate(result) mustTruncate(result)
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
@@ -1190,7 +1149,6 @@ describe("tool.shell truncation", () => {
const byteCount = Truncate.MAX_BYTES + 10000 const byteCount = Truncate.MAX_BYTES + 10000
const result = yield* run({ const result = yield* run({
command: fill("bytes", byteCount), command: fill("bytes", byteCount),
description: "Generate bytes exceeding limit",
}) })
mustTruncate(result) mustTruncate(result)
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
@@ -1205,7 +1163,6 @@ describe("tool.shell truncation", () => {
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* run({ const result = yield* run({
command: fill("lines", 1), command: fill("lines", 1),
description: "Generate one line",
}) })
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
expect(result.output).toContain("1") expect(result.output).toContain("1")
@@ -1220,7 +1177,6 @@ describe("tool.shell truncation", () => {
const lineCount = Truncate.MAX_LINES + 100 const lineCount = Truncate.MAX_LINES + 100
const result = yield* run({ const result = yield* run({
command: fill("lines", lineCount), command: fill("lines", lineCount),
description: "Generate lines for file check",
}) })
mustTruncate(result) mustTruncate(result)
+7 -4
View File
@@ -35,6 +35,7 @@ import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading" import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync" import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data" import { DataProvider } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local" import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model" import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected" import { useConnected } from "./component/use-connected"
@@ -303,10 +304,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptHistoryProvider> <PromptHistoryProvider>
<PromptRefProvider> <PromptRefProvider>
<EditorContextProvider> <EditorContextProvider>
<App <LocationProvider>
onSnapshot={input.onSnapshot} <App
pluginHost={input.pluginHost} onSnapshot={input.onSnapshot}
/> pluginHost={input.pluginHost}
/>
</LocationProvider>
</EditorContextProvider> </EditorContextProvider>
</PromptRefProvider> </PromptRefProvider>
</PromptHistoryProvider> </PromptHistoryProvider>
@@ -13,6 +13,7 @@ import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime" import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
@@ -21,6 +22,7 @@ import type { PromptInfo } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency" import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
function removeLineRange(input: string) { function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#") const hashIndex = input.lastIndexOf("#")
@@ -94,6 +96,7 @@ export function Autocomplete(props: {
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const paths = useTuiPaths() const paths = useTuiPaths()
const location = useLocation()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
selected: 0, selected: 0,
@@ -236,16 +239,18 @@ export function Autocomplete(props: {
} }
} }
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { function createFilePart(
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") item: FileSystemEntry,
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) filePath: string,
const urlObj = pathToFileURL(fullPath) lineRange?: { startLine: number; endLine?: number },
) {
const urlObj = pathToFileURL(filePath)
const filename = const filename =
lineRange && !item.endsWith("/") lineRange && item.type !== "directory"
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item : item.path
if (lineRange && !item.endsWith("/")) { if (lineRange && item.type !== "directory") {
urlObj.searchParams.set("start", String(lineRange.startLine)) urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) { if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine)) urlObj.searchParams.set("end", String(lineRange.endLine))
@@ -254,10 +259,9 @@ export function Autocomplete(props: {
return { return {
filename, filename,
url: urlObj.href,
part: { part: {
type: "file" as const, type: "file" as const,
mime: "text/plain", mime: item.mime,
filename, filename,
url: urlObj.href, url: urlObj.href,
source: { source: {
@@ -267,7 +271,7 @@ export function Autocomplete(props: {
end: 0, end: 0,
value: "", value: "",
}, },
path: item, path: item.path,
}, },
}, },
} }
@@ -284,7 +288,7 @@ export function Autocomplete(props: {
}) })
function normalizeMentionPath(filePath: string) { function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || paths.cwd const baseDir = location()?.directory || sync.path.directory || paths.cwd
const absolute = path.resolve(filePath) const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute) const relative = path.relative(baseDir, absolute)
@@ -301,7 +305,11 @@ export function Autocomplete(props: {
startLine: input.lineStart, startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
} }
const { filename, part } = createFilePart(item, lineRange) const { filename, part } = createFilePart(
{ path: item, type: "file", mime: "text/plain" },
input.filePath,
lineRange,
)
const index = store.visible === "@" ? store.index : props.input().cursorOffset const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false) setStore("visible", false)
@@ -310,17 +318,20 @@ export function Autocomplete(props: {
} }
const [files] = createResource( const [files] = createResource(
() => search(), () => ({ query: search(), location: location() }),
async (query) => { async (input) => {
if (!store.visible || store.visible === "/") return [] if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return [] if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(query ?? "") const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
// Get files from SDK // Get files from SDK
const result = await sdk.client.v2.fs.find({ const result = await sdk.client.v2.fs.find({
query: baseQuery, query: baseQuery,
limit: "20", limit: "20",
location: { workspace: project.workspace.current() }, location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(),
},
}) })
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
@@ -331,7 +342,11 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...result.data.data.map((item): AutocompleteOption => { ...result.data.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item.path, lineRange) const { filename, part } = createFilePart(
item,
path.join(result.data.location.directory, item.path),
lineRange,
)
return { return {
display: Locale.truncateMiddle(filename, width), display: Locale.truncateMiddle(filename, width),
value: filename, value: filename,
+14
View File
@@ -0,0 +1,14 @@
import type { LocationRef } from "@opencode-ai/sdk/v2"
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
const context = createContext<Accessor<LocationRef | undefined>>()
export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) {
return <context.Provider value={() => props.location}>{props.children}</context.Provider>
}
export function useLocation() {
const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider")
return value
}
+7 -23
View File
@@ -1,31 +1,15 @@
import path from "path" import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { abbreviateHome } from "../runtime" import { abbreviateHome } from "../runtime"
import { useLocation } from "./location"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
const context = createContext<{
path: () => string
format: (input?: string) => string
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
const paths = useTuiPaths()
return (
<context.Provider
value={{
path: () => props.path || paths.cwd,
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
}}
>
{props.children}
</context.Provider>
)
}
export function usePathFormatter() { export function usePathFormatter() {
const value = useContext(context) const paths = useTuiPaths()
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") const location = useLocation()
return value return {
path: () => location()?.directory || paths.cwd,
format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home),
}
} }
function formatPath(input: string | undefined, base: string, home: string) { function formatPath(input: string | undefined, base: string, home: string) {
+30 -20
View File
@@ -80,7 +80,8 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action" import { DialogRetryAction } from "../../component/dialog-retry-action"
import { getRevertDiffFiles } from "../../util/revert-diff" import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -193,6 +194,10 @@ export function Session() {
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID)) const session = createMemo(() => sync.session.get(route.sessionID))
const location = createMemo(() => {
const current = session()
return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined
})
createEffect(() => { createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50) const title = Locale.truncate(session()?.title ?? "", 50)
@@ -1138,7 +1143,7 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom)) createEffect(on(() => route.sessionID, toBottom))
return ( return (
<PathFormatterProvider path={session()?.directory}> <LocationProvider location={location()}>
<context.Provider <context.Provider
value={{ value={{
get width() { get width() {
@@ -1338,7 +1343,7 @@ export function Session() {
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
</PathFormatterProvider> </LocationProvider>
) )
} }
@@ -1989,7 +1994,7 @@ export function InlineToolRow(props: {
} }
function BlockTool(props: { function BlockTool(props: {
title: string title?: string
children: JSX.Element children: JSX.Element
onClick?: () => void onClick?: () => void
part?: ToolPart part?: ToolPart
@@ -2018,15 +2023,19 @@ function BlockTool(props: {
props.onClick?.() props.onClick?.()
}} }}
> >
<Show <Show when={props.title}>
when={props.spinner} {(title) => (
fallback={ <Show
<text paddingLeft={3} fg={theme.textMuted}> when={props.spinner}
{props.title} fallback={
</text> <text paddingLeft={3} fg={theme.textMuted}>
} {title()}
> </text>
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner> }
>
<Spinner color={theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
</Show>
)}
</Show> </Show>
{props.children} {props.children}
<Show when={error()}> <Show when={error()}>
@@ -2054,15 +2063,15 @@ function Shell(props: ToolProps) {
const workdirDisplay = createMemo(() => { const workdirDisplay = createMemo(() => {
const workdir = stringValue(props.input.workdir) const workdir = stringValue(props.input.workdir)
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
return pathFormatter.format(workdir) const formatted = pathFormatter.format(workdir)
if (formatted === ".") return undefined
return formatted
}) })
const title = createMemo(() => { const title = createMemo(() => {
const desc = stringValue(props.input.description) ?? "Shell"
const wd = workdirDisplay() const wd = workdirDisplay()
if (!wd) return `# ${desc}` if (!wd) return
if (desc.includes(wd)) return `# ${desc}` return `# Running in ${wd}`
return `# ${desc} in ${wd}`
}) })
return ( return (
@@ -2071,11 +2080,12 @@ function Shell(props: ToolProps) {
<BlockTool <BlockTool
title={title()} title={title()}
part={props.part} part={props.part}
spinner={isRunning()}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
> >
<box gap={1}> <box gap={1}>
<text fg={theme.text}>$ {stringValue(props.input.command)}</text> <Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
</Show>
<Show when={output()}> <Show when={output()}>
<text fg={theme.text}>{limited()}</text> <text fg={theme.text}>{limited()}</text>
</Show> </Show>
@@ -269,12 +269,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
} }
if (permission === "bash") { if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : "" const command = typeof data.command === "string" ? data.command : ""
return { return {
icon: "#", icon: "#",
title, title: "Shell command",
body: ( body: (
<Show when={command}> <Show when={command}>
<box paddingLeft={1}> <box paddingLeft={1}>
@@ -27,8 +27,6 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
" "
# List files
$ ls $ ls
file.ts file.ts
@@ -62,7 +62,6 @@ function ShellOutput() {
paddingLeft={2} paddingLeft={2}
gap={1} gap={1}
> >
<text paddingLeft={3}># List files</text>
<box gap={1}> <box gap={1}>
<text>$ ls</text> <text>$ ls</text>
<text>file.ts</text> <text>file.ts</text>
+4 -2
View File
@@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion" import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -24,7 +24,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
export interface BasicToolProps { export interface BasicToolProps {
icon: IconProps["name"] icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
children?: JSX.Element children?: JSX.Element
status?: string status?: string
hideDetails?: boolean hideDetails?: boolean
@@ -89,6 +89,7 @@ export function BasicTool(props: BasicToolProps) {
const ready = () => state.ready const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running" const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children) const hasChildren = () => (props.defer ? "children" in props : props.children)
const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
let cancelReady: (() => void) | undefined let cancelReady: (() => void) | undefined
@@ -187,6 +188,7 @@ export function BasicTool(props: BasicToolProps) {
<div data-slot="basic-tool-tool-trigger-content"> <div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info"> <div data-slot="basic-tool-tool-info">
<Switch> <Switch>
<Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
<Match when={isTriggerTitle(props.trigger) && props.trigger}> <Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(title) => ( {(title) => (
<div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-structured">
+5 -5
View File
@@ -422,7 +422,7 @@ export function getToolInfo(
return { return {
icon: "console", icon: "console",
title: i18n.t("ui.tool.shell"), title: i18n.t("ui.tool.shell"),
subtitle: input.description, subtitle: input.command,
} }
case "edit": case "edit":
return { return {
@@ -1905,18 +1905,18 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="console" icon="console"
trigger={ trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main"> <div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title"> <span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} /> <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span> </span>
<Show when={!pending() && props.input.description}> <Show when={!pending() && !open() && props.input.command}>
<ShellSubmessage text={props.input.description} animate={sawPending} /> <ShellSubmessage text={props.input.command} animate={sawPending} />
</Show> </Show>
</div> </div>
</div> </div>
} )}
> >
<div data-component="bash-output"> <div data-component="bash-output">
<div data-slot="bash-copy"> <div data-slot="bash-copy">
@@ -386,7 +386,7 @@ export const SessionReview = (props: SessionReviewProps) => {
> >
<div data-slot="session-review-container" class={props.classes?.container}> <div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}> <Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6"> <div data-slot="session-review-list" class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}> <Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
@@ -316,10 +316,10 @@ const TOOL_SAMPLES = {
}, },
bash: { bash: {
tool: "bash", tool: "bash",
input: { command: "bun test --filter session", description: "Run session tests" }, input: { command: "bun test --filter session" },
output: output:
"bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", "bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
title: "Run session tests", title: "bun test --filter session",
metadata: { command: "bun test --filter session" }, metadata: { command: "bun test --filter session" },
}, },
edit: { edit: {
@@ -6,7 +6,6 @@ import { codeToHtml } from "shiki"
interface Props { interface Props {
command: string command: string
output: string output: string
description?: string
expand?: boolean expand?: boolean
} }
@@ -45,7 +44,7 @@ export function ContentBash(props: Props) {
<div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}> <div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}>
<div data-slot="body"> <div data-slot="body">
<div data-slot="header"> <div data-slot="header">
<span>{props.description}</span> <span>Shell</span>
</div> </div>
<div data-slot="content"> <div data-slot="content">
<div innerHTML={commandHtml()} /> <div innerHTML={commandHtml()} />
@@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) {
<ContentBash <ContentBash
command={props.state.input.command} command={props.state.input.command}
output={props.state.metadata.output ?? props.state.metadata?.stdout} output={props.state.metadata.output ?? props.state.metadata?.stdout}
description={props.state.metadata.description}
/> />
) )
} }
+16
View File
@@ -714,6 +714,22 @@ Compatibility:
- Foreground V2 bash execution is unchanged. - Foreground V2 bash execution is unchanged.
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
## 2026-06-18: Remove Bash Description Input
Affected schema:
- V1 and Core V2 model-facing `bash` tool parameters.
Change:
- Remove the V1 required and V2 optional `description` parameter.
- Derive shell presentation from the command or a generic shell label instead of model-authored description metadata.
Compatibility:
- Existing persisted tool calls may still contain `description`, but new tool definitions no longer expose or require it.
- Shell command execution behavior is unchanged.
## 2026-06-04: Add Durable Session Context Snapshots ## 2026-06-04: Add Durable Session Context Snapshots
Affected schema: Affected schema: