Compare commits

..
16 Commits
Author SHA1 Message Date
Dax dbd9b18f3d refactor(cli): move import and export under session (#48055) 2026-09-08 21:59:58 -04:00
opencode-agent[bot]andBrendonovich b43e1c682b test(app): remove subagent link coverage (#48053)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 01:54:59 +00:00
opencode-agent[bot]andBrendonovich d11f5916ee test(app): remove subagent link coverage (#48052)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 01:52:09 +00:00
opencode-agent[bot]andBrendonovich f1ce69d2ce fix(app): link background subagents to their sessions (#47455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 09:39:22 +08:00
Brendan Allan d39290fbb9 feat(desktop): add SSH server connections (#47753) 2026-09-09 09:32:38 +08:00
Aiden Cline 6ee2ed7510 refactor(session): remove message content mutation API (#48043) 2026-09-08 20:27:22 -05:00
Dax 594635b5ae feat(cli): add auth account switching and targeted logout (#48050) 2026-09-08 21:27:21 -04:00
David Hill 18b05e86fb feat(app): add browser shortcut to new-tab menu (#48044) 2026-09-09 00:03:10 +00:00
Dax cc501650c6 refactor(cli): remove console command (#48040) 2026-09-08 19:54:47 -04:00
David HillandLukeParkerDev 37b6fbc5bf fix(app): move vertical tab update button to footer (#48030)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-09 09:41:10 +10:00
Aiden Cline d24f8b0810 fix(core): discourage polling background shell commands (#48041) 2026-09-08 18:34:59 -05:00
Aiden Cline 79a6a90862 fix(plugin): normalize promise API inputs (#48039) 2026-09-08 18:34:49 -05:00
David Hill 8c5eca5bb2 fix(session-ui): show one count before used (#48038) 2026-09-09 09:15:24 +10:00
David Hill f3128fa241 fix(ui): unify composer submit button styles (#48032) 2026-09-09 09:07:41 +10:00
David Hill 883d16d2ad fix(session-ui): emphasize mentions and soften at prefixes (#48029) 2026-09-09 09:05:13 +10:00
Dax Raad 5c30292daa fix(update): restore next channel compatibility alias 2026-09-08 16:57:16 -04:00
160 changed files with 5158 additions and 2131 deletions
@@ -0,0 +1,67 @@
import { expect, story } from "../../storybook/playwright/story"
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--settings-reconnect")
await component.getByRole("button", { name: "More options" }).click()
await page.getByRole("menuitem", { name: "Connect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeVisible()
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue" }).click()
await expect(dialog.getByRole("textbox", { name: "Verification code:" })).toBeVisible()
await dialog.getByRole("textbox").fill("123456")
await dialog.getByRole("button", { name: "Continue" }).click()
await expect(dialog).toHaveCount(0)
await expect(component.getByRole("button", { name: "Authenticate", exact: true })).toHaveCount(0)
})
story("cancelling a version mismatch permits reconnecting again", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
story("adding a server keeps all SSH challenges in the original connection dialog", async ({ mount, page }) => {
await mount("app-dialog-ssh--host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog).toHaveCount(1)
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeFocused()
await dialog.getByRole("button", { name: "Trust and connect" }).click()
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("button", { name: "Update and reconnect", exact: true }).click()
await expect(dialog).toHaveCount(1)
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(component.getByText("Session connected")).toBeVisible()
})
story("key-based reconnect completes without opening an authentication dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--key-reconnect")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(component.getByRole("button", { name: "Connecting to SSH server" })).toBeDisabled()
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(component.getByText("Session connected")).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
})
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
const patch = page.locator('[data-component="apply-patch-tool"]')
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toHaveText(/^Used\s*1 Read, 1 Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep")
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*1 Read, 1 Grep$/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/^Used\s*1 Read, 1 Grep$/,
/^2 used\s*Read, Grep$/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
])
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
await expect(summary).toHaveAccessibleName("Used 1 Patch")
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
await expect(summary).toHaveAccessibleName("1 used Patch")
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
@@ -110,8 +110,8 @@ test.describe("regression: session timeline local row state", () => {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Used 1 Read, 1 Glob, 1 Grep, 1 List"])
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -16,8 +16,8 @@ for (const locale of ["de", "ar"] as const) {
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const names = locale === "de" ? "1 Lesen, 1 Glob" : "1 \u0642\u0631\u0627\u0621\u0629, 1 Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`Used ${names}`)
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
@@ -386,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await page.getByRole("button", { name: "1 used Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
@@ -45,10 +45,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used 2 Shell, 1 Agent, 1 Patch", exact: true })
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Shell, 1 Agent, 1 Patch")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
@@ -75,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used 1 Patch, 1 Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 Read")
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 1 Shell, 1 Patch", exact: true }).click()
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
),
),
)
await expect(group.getByRole("button", { name: "Used 1 Shell, 2 Patch", exact: true })).toHaveAttribute(
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
@@ -162,7 +162,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
await expect(working).toHaveCount(0)
return
}
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
await trigger.click()
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
+1
View File
@@ -10,6 +10,7 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
+6 -1
View File
@@ -17,6 +17,8 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { SshProvider } from "@/servers/ssh/context"
import { SshRestore } from "@/servers/ssh/restore"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
@@ -81,7 +83,9 @@ export function AppBaseProviders(
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
<SshProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</SshProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
@@ -109,6 +113,7 @@ export function AppInterface(props: {
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
+2 -2
View File
@@ -841,8 +841,8 @@ export function ComposerEditorSubmitButton(props: {
disabled={!props.stopping && props.disabled}
tabIndex={props.mode === "normal" ? undefined : -1}
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
variant="contrast"
class="size-7 rounded-md p-[6px] disabled:opacity-50"
variant="submit"
class="size-7 rounded-md p-[6px]"
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
@@ -37,6 +37,7 @@ export type ComposerEditorView = {
agent?: ComposerSelectControl
variant?: ComposerSelectControl
submit: {
available?: Accessor<boolean>
stopping: Accessor<boolean>
working?: Accessor<boolean>
queue?: ComposerQueue
@@ -333,6 +334,7 @@ export function createComposerEditor(input: {
draft.removeAttachment(id)
},
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
const persisted = draft.state
if (state.mode === "shell") {
@@ -365,6 +367,7 @@ export function createComposerEditor(input: {
dispatch({ type: "mode.shell" })
},
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
+5 -1
View File
@@ -11,7 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
@@ -30,6 +30,8 @@ export type ComposerModel = ComposerEditorModel & {
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const server = useServer()
const available = () => server.conn.type !== "ssh" || server.ctx.sdk.connection.status() === "connected"
const files = useFile()
const layout = useLayout()
const comments = useComments()
@@ -394,10 +396,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
available,
stopping,
working: adapter.working,
queue: options?.queue,
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
+1
View File
@@ -20,4 +20,5 @@ export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { useSsh } from "./servers/ssh/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+26 -9
View File
@@ -15,6 +15,7 @@ import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -28,6 +29,7 @@ export function createHomeProjectsController(home: HomeController) {
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = useSshAuthenticate()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
@@ -42,6 +44,15 @@ export function createHomeProjectsController(home: HomeController) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function choose(conn: ServerConnection.Any) {
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
})
}
return {
copy: {
language,
@@ -71,15 +82,25 @@ export function createHomeProjectsController(home: HomeController) {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
focus: home.selection.focusServer,
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => home.selection.focusServer(conn))) return
home.selection.focusServer(conn)
},
},
project: {
list: home.project.list,
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: home.project.select,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
home.project.openProjectNewSession(conn, directory)
},
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
@@ -125,13 +146,9 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => choose(conn))) return
if (home.server.health(conn)?.healthy === false) return
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
})
choose(conn)
},
close: (conn: ServerConnection.Any, directory: string) => {
const next = closeHomeProject(
@@ -26,6 +26,7 @@ export function HomeProjects(props: {
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
onFocusServer={props.projects.server.focus}
onAuthenticateServer={props.projects.server.authenticate}
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
+59
View File
@@ -0,0 +1,59 @@
/* Home server/project rows keep the label underneath the hover actions.
The actions carry a tab-style background with a fade on the left, and the
label fades out where it slides underneath. Mirrors tab-nav.css. */
[data-home-row] {
--home-row-surface: var(--v2-background-bg-base);
--home-row-background: color-mix(
in srgb,
var(--home-row-surface) var(--home-row-opacity, 100%),
var(--v2-background-bg-base)
);
background: var(--home-row-background);
}
[data-home-row]:is(:hover, :focus-within, [data-dragging="true"], :has([data-menu="true"])) {
--home-row-surface: var(--v2-background-bg-layer-01);
}
[data-home-row][data-selected] {
--home-row-surface: var(--v2-background-bg-layer-03);
}
[data-home-row][data-dimmed="true"] {
--home-row-opacity: 60%;
}
/* Keep the background outside the button's disabled-content opacity. */
[data-home-row] > button {
background: transparent;
}
[data-home-row] [data-slot="home-row-actions"] {
background: linear-gradient(to right, transparent, var(--home-row-background) 8px);
}
[data-home-row]:dir(rtl) [data-slot="home-row-actions"] {
background: linear-gradient(to left, transparent, var(--home-row-background) 8px);
}
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])):dir(rtl) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
@media (hover: none) {
[data-home-row] [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
[data-home-row]:dir(rtl) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
}
+74 -16
View File
@@ -10,6 +10,8 @@ import { ScrollView } from "@opencode/ui/scroll-view"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
@@ -21,6 +23,7 @@ import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu
import { ServerHealthIndicator } from "@/servers/registry/row"
import { type ServerHealth } from "@/runtime/server/health"
import { fileManagerApp } from "@/home/projects/file-manager"
import "./view.css"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
@@ -46,6 +49,7 @@ export type HomeProjectsViewProps = {
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
onAuthenticateServer?: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
@@ -122,6 +126,10 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
props.onFocusServer(server)
setState("open", false)
}}
onAuthenticateServer={(server) => {
setState("open", false)
props.onAuthenticateServer?.(server)
}}
onChooseProject={(server) => {
setState("open", false)
props.onChooseProject(server)
@@ -196,7 +204,12 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
</HomeProjectNavButton>
</Show>
<Show
when={props.servers.length > 1}
when={
props.servers.length > 1 ||
props.servers.some(
(server) => server.type === "ssh" && (server.authenticationRequired || server.connecting),
)
}
fallback={
<Show when={props.servers[0]}>
{(server) => (
@@ -231,6 +244,8 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
const authentication = () => item.type === "ssh" && item.authenticationRequired
const connecting = () => item.type === "ssh" && item.connecting
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
@@ -241,7 +256,26 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<Show when={authentication() || connecting()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<div class="px-1.5 py-1">
<Button
data-action="home-server-authenticate"
class="w-full"
size="small"
variant="neutral"
disabled={connecting()}
aria-busy={!!connecting()}
onClick={() => props.onAuthenticateServer?.(item)}
>
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
</Button>
</div>
</Show>
<Show when={healthy() && !authentication() && !connecting() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
</Show>
@@ -314,6 +348,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const authentication = () => props.server.type === "ssh" && props.server.authenticationRequired
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
@@ -326,16 +361,23 @@ function HomeServerRow(props: {
appearance="standard"
placement="top"
class="flex h-7 w-full min-w-0"
inactive={!incompatible()}
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
inactive={!incompatible() && !authentication()}
value={
authentication()
? props.language.t("ssh.stage.authentication")
: props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })
}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<div
class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]"
data-home-row
data-dimmed={!healthy() && !incompatible()}
data-selected={props.selected ? "" : undefined}
>
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
disabled={!healthy() && !authentication()}
onClick={() => props.onFocusServer(props.server)}
>
<span
@@ -369,10 +411,18 @@ function HomeServerRow(props: {
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
<ServerHealthIndicator
health={props.health}
connecting={props.server.type === "ssh" && props.server.connecting}
authenticationRequired={authentication()}
/>
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>
<span
data-slot="home-row-label"
class="flex min-w-0 flex-1 items-center gap-1"
classList={{ "opacity-60": !healthy() && !incompatible() }}
>
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{props.server.displayName ?? new URL(props.server.http.url).host}
</span>
<Show when={props.server.label}>
@@ -390,8 +440,9 @@ function HomeServerRow(props: {
</span>
</HomeProjectNavButton>
<div
data-slot="home-row-actions"
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
@@ -418,7 +469,7 @@ function HomeServerRow(props: {
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
disabled={props.health?.healthy === false && !authentication()}
onClick={() => props.onChooseProject(props.server)}
/>
</Tooltip>
@@ -602,6 +653,10 @@ function HomeProjectRow(
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
data-home-row
data-dimmed={serverUnreachable()}
data-dragging={sortable.isDragSource()}
data-selected={props.selected ? "" : undefined}
onContextMenu={(event) => {
event.preventDefault()
props.onSetContextMenuOpen(contextMenuID(), true)
@@ -610,7 +665,7 @@ function HomeProjectRow(
<HomeProjectNavButton
type="button"
data-component="home-project-row"
class="pr-16 disabled:opacity-60"
class="disabled:opacity-60"
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
}}
@@ -647,11 +702,14 @@ function HomeProjectRow(
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
<span data-slot="home-row-label" class={HOME_PROJECT_NAV_LABEL}>
{displayName(props.project)}
</span>
</HomeProjectNavButton>
<div
data-slot="home-row-actions"
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
+49
View File
@@ -2,6 +2,54 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"ssh.label": "SSH",
"ssh.offline": "Not connected to {{host}}. Your draft is preserved; remote work may still be running.",
"ssh.placeholder": "ssh user@example.com",
"ssh.add": "Add SSH server",
"ssh.server.menu.label": "SSH server",
"ssh.target": "Host or SSH command",
"ssh.connect": "Connect",
"ssh.connectTo": "Connect to {{host}}",
"ssh.authenticate": "SSH authentication",
"ssh.action.authenticate": "Authenticate",
"ssh.session.disconnected": "SSH connection inactive",
"ssh.session.connecting": "Connecting to SSH server",
"ssh.session.reconnectDescription":
"Reconnect to view this session and continue working. Your remote session is preserved.",
"ssh.session.reconnect": "Reconnect",
"ssh.authenticationRequired": "Authentication required for {{host}}",
"ssh.trust": "Trust and connect",
"ssh.continue": "Continue",
"ssh.retry": "Retry",
"ssh.update": "Update and reconnect",
"ssh.openProject": "Open project",
"ssh.project": "Open project on {{host}}",
"ssh.disconnect": "Disconnect",
"ssh.forget": "Forget connection",
"ssh.stage.disconnected": "Disconnected. The remote server is left running.",
"ssh.stage.connecting": "Connecting over SSH…",
"ssh.stage.checking": "Checking OpenCode…",
"ssh.stage.downloading": "Downloading server…",
"ssh.stage.uploading": "Uploading server…",
"ssh.stage.starting": "Connecting to OpenCode…",
"ssh.stage.ready": "Connected",
"ssh.stage.authentication": "Authentication required",
"ssh.stage.incompatible": "Server update required",
"ssh.stage.failed": "Connection failed",
"ssh.error.input":
"Enter a host or SSH connection command. Remote commands and unsupported SSH options arent allowed.",
"ssh.error.connection": "Could not establish the SSH connection. Check your network and SSH configuration.",
"ssh.error.platform":
"This remote platform is not supported. Automatic setup currently requires Linux or macOS on x64 or arm64.",
"ssh.error.version": "The remote service must match this Desktop version before connecting.",
"ssh.error.install":
"Could not install the remote server. Check connectivity, disk space, and that tar is installed.",
"ssh.error.unpublished":
"This Desktop version has no published remote server. For development builds, install and start V2 on the host, then retry.",
"ssh.error.service": "SSH connected, but the OpenCode server did not become ready.",
"ssh.error.host-key":
"The hosts identity could not be verified. Verify its fingerprint before updating your SSH known hosts.",
"ssh.error.ssh-missing": "OpenSSH was not found. Install an OpenSSH client and ensure ssh is available on PATH.",
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
@@ -58,6 +106,7 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.browser.open": "Open browser",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
"command.context.addSelection": "Add selection to context",
@@ -4,6 +4,7 @@ import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "@/shell/commands/desktop-menu"
import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { SshPlatform } from "@/servers/ssh/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
@@ -86,6 +87,7 @@ type PlatformBase = {
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
sshServers?: SshPlatform
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
+12 -2
View File
@@ -1,7 +1,7 @@
import type { OpenCodeEvent } from "@opencode/client/promise"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { type Accessor, createEffect, on, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
@@ -74,8 +74,18 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
if (server.type === "ssh") {
createEffect(
on(
() => `${server.http.url}\0${server.http.password ?? ""}`,
() => transport.update(server.http),
{ defer: true },
),
)
}
const events = createOpenCodeEventSource()
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
const reconnect =
server.type === "ssh" || (server.type === "sidecar" && server.variant === "base") ? server.reconnect : undefined
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
+56 -8
View File
@@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean }
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -142,25 +142,73 @@ export function useCheckServerHealth() {
}
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
return createServerHealth(servers, enabled, useCheckServerHealth())
}
export function createServerHealth(
servers: Accessor<ServerConnection.Any[]>,
enabled: Accessor<boolean>,
check: (http: ServerConnection.HttpBase) => Promise<ServerHealth>,
) {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
const endpoints = new Map<ServerConnection.Key, string>()
createEffect(() => {
if (!enabled()) {
endpoints.clear()
setStatus(reconcile({}))
return
}
const list = servers()
// Snapshot transport fields synchronously so a newly established SSH tunnel
// invalidates both the old result and any probe still using the old endpoint.
const list = servers().map((conn) => ({
key: ServerConnection.key(conn),
type: conn.type,
http: conn.http,
stage: conn.type === "ssh" ? conn.stage : undefined,
}))
for (const conn of list) {
if (conn.stage && conn.stage !== "ready") {
endpoints.delete(conn.key)
setStatus(
conn.key,
reconcile(
conn.stage === "failed"
? { healthy: false }
: conn.stage === "incompatible"
? { healthy: false, incompatible: true }
: undefined,
),
)
continue
}
const endpoint = cacheKey(conn.http)
if (conn.type === "ssh" && endpoints.get(conn.key) !== endpoint) {
setStatus(conn.key, reconcile({ healthy: false, checking: true }))
}
endpoints.set(conn.key, endpoint)
}
for (const key of endpoints.keys()) {
if (!list.some((conn) => conn.key === key)) endpoints.delete(key)
}
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
const results: Record<string, ServerHealth | undefined> = {}
await Promise.all(
list.map(async (conn) => {
const key = ServerConnection.key(conn)
const result = await checkServerHealth(conn.http)
results[key] = result
if (!dead) setStatus(key, result)
if (conn.stage && conn.stage !== "ready") {
results[conn.key] =
conn.stage === "failed"
? { healthy: false }
: conn.stage === "incompatible"
? { healthy: false, incompatible: true }
: undefined
return
}
const result = await check(conn.http)
results[conn.key] = result
if (!dead) setStatus(conn.key, reconcile(result))
}),
)
if (dead) return
+8 -1
View File
@@ -5,6 +5,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { pathKey } from "@/workspaces/path-key"
import { ServerScope } from "@/runtime/server/scope"
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
@@ -24,6 +25,7 @@ export function normalizeServerUrl(input: string) {
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
if (conn.type === "ssh") return conn.host
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
@@ -159,9 +161,14 @@ export namespace ServerConnection {
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
stage?: SshItem["stage"]
connecting?: boolean
authenticationRequired?: boolean
id?: string
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
reconnect?: (signal: AbortSignal) => Promise<HttpBase>
} & Base
export type Any =
@@ -178,7 +185,7 @@ export namespace ServerConnection {
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
return Key.make(`ssh:${conn.id ?? conn.host}`)
}
}
@@ -8,6 +8,7 @@ import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
import { useSsh } from "../ssh/context"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
@@ -50,6 +51,7 @@ function useDefaultServer() {
export function useServerActionsController() {
const server = useServers()
const ssh = useSsh()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -58,6 +60,7 @@ export function useServerActionsController() {
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
if (key.startsWith("ssh:")) await ssh.forget(key.slice(4))
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
@@ -5,6 +5,7 @@ import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/servers/registry/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { SshMenu } from "../ssh/menu"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
@@ -15,6 +16,7 @@ export const ServerRowMenu: Component<{
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
if (props.server.type === "ssh" && props.server.id) return <SshMenu id={props.server.id} domain={props.domain} />
return (
<ServerRowMenuView
server={props.server}
@@ -0,0 +1,44 @@
import { For } from "solid-js"
import { ServerHealthIndicator } from "./row"
import type { ServerHealth } from "@/runtime/server/health"
const states: { label: string; connecting?: boolean; authenticationRequired?: boolean; health?: ServerHealth }[] = [
{
label: "Authentication required (overrides failed health)",
authenticationRequired: true,
health: { healthy: false },
},
{
label: "Authentication required (overrides pending health)",
authenticationRequired: true,
health: { healthy: false, checking: true },
},
{ label: "Connecting (previous health check failed)", connecting: true, health: { healthy: false } },
{ label: "Tunnel ready, checking its new endpoint", health: { healthy: false, checking: true } },
{ label: "Connected", health: { healthy: true } },
{ label: "Failed", health: { healthy: false } },
{ label: "Incompatible", health: { healthy: false, incompatible: true } },
{ label: "Not checked" },
]
export default { title: "App/Servers/Health indicator", id: "app-server-health" }
export const States = {
render: () => (
<div class="flex flex-col gap-4">
<For each={states}>
{(state) => (
<div class="flex items-center gap-2">
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator
health={state.health}
connecting={state.connecting}
authenticationRequired={state.authenticationRequired}
/>
</div>
<span>{state.label}</span>
</div>
)}
</For>
</div>
),
}
+44 -11
View File
@@ -1,5 +1,7 @@
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Spinner } from "@opencode/ui/spinner"
import { useLanguage } from "@/runtime/i18n/language"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
@@ -102,22 +104,53 @@ export function ServerRow(props: ServerRowProps) {
)
}
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
export function ServerHealthIndicator(props: {
health?: ServerHealth
connecting?: boolean
authenticationRequired?: boolean
}) {
const language = useLanguage()
return (
<Show
when={props.health?.incompatible}
when={props.authenticationRequired}
fallback={
<div
classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
<Show
when={props.connecting || props.health?.checking}
fallback={
<Show
when={props.health?.incompatible}
fallback={
<div
classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
}
>
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
</Show>
}
>
<span
role="status"
aria-label={language.t("ssh.stage.connecting")}
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
>
<Spinner class="size-3 shrink-0" />
</span>
</Show>
}
>
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
<span
role="status"
aria-label={language.t("ssh.stage.authentication")}
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
>
<Icon name="lock" size="small" class="shrink-0" />
</span>
</Show>
)
}
@@ -0,0 +1,13 @@
import { useSsh } from "./context"
import type { ServerConnection } from "@/runtime/server/registry"
export function useSshAuthenticate() {
const ssh = useSsh()
return (server: ServerConnection.Any, onConnected?: () => void) => {
if (server.type !== "ssh" || !server.authenticationRequired) return false
const item = ssh.servers.find((item) => item.config.id === server.id)
if (!item) return false
ssh.connect(item.config, { onConnected })
return true
}
}
@@ -0,0 +1,25 @@
import { createEffect } from "solid-js"
import type { SshItem } from "./types"
// Offer authentication once per selected tab. Cancelling must not immediately
// reopen the prompt; background hosts never open a dialog here.
export function createSshAuthentication(input: {
selection: () => string | undefined
item: () => SshItem | undefined
busy: () => boolean
open: (item: SshItem) => void
}) {
const state = { selection: undefined as string | undefined, offered: false }
createEffect(() => {
const selection = input.selection()
if (state.selection !== selection) {
state.selection = selection
state.offered = false
}
const item = input.item()
if (!selection || state.offered || item?.stage !== "authentication" || item.authenticatingElsewhere || input.busy())
return
state.offered = true
input.open(item)
})
}
@@ -0,0 +1,59 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useCurrentRoute } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useSsh } from "./context"
import { createSshAuthentication } from "./authentication-state"
import { SshConnectionPanel } from "./connection-panel"
export function SshAuthentication(props: ParentProps) {
const ssh = useSsh()
const route = useCurrentRoute()
const tabs = useTabs()
const dialog = useDialog()
const item = createMemo(() => {
const current = route()
const key =
current.type === "session"
? current.server
: current.type === "draft"
? tabs.store.find((tab) => tab.type === "draft" && tab.draftID === current.draftID)?.server
: undefined
return ssh.servers.find((item) => `ssh:${item.config.id}` === key && item.stage !== "ready")
})
createSshAuthentication({
selection: () => {
const current = route()
if (current.type === "session") return `${current.server}:${current.sessionId}`
if (current.type === "draft") return current.draftID
return undefined
},
item,
busy: () => !!dialog.active,
open: (item) => ssh.connect(item.config),
})
return (
<div class="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Keep the route mounted so reconnecting preserves its draft and local UI state. */}
<div
class="flex size-full min-h-0 min-w-0 flex-col"
classList={{ invisible: !!item() }}
inert={!!item()}
aria-hidden={item() ? true : undefined}
>
{props.children}
</div>
<Show when={item()}>
{(item) => (
<div class="absolute inset-0">
<SshConnectionPanel
item={item()}
pending={ssh.pending(item().config.id)}
onReconnect={() => ssh.connect(item().config)}
/>
</div>
)}
</Show>
</div>
)
}
@@ -0,0 +1,47 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Spinner } from "@opencode/ui/spinner"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
import type { SshItem } from "./types"
export function SshConnectionPanel(props: { item: SshItem; pending?: boolean; onReconnect: () => void }) {
const language = useLanguage()
const connecting = () => props.pending || isSshConnecting(props.item.stage)
return (
<section
data-component="ssh-connection-panel"
class="flex h-full min-h-0 flex-col items-center justify-center gap-4 overflow-y-auto bg-v2-background-bg-base px-6 py-8 text-center"
>
<Icon name="lock" size="large" class="text-v2-icon-icon-muted" />
<div class="flex max-w-sm flex-col items-center gap-2" role="status" aria-live="polite">
<h2 class="text-16-medium text-v2-text-text-base">{language.t("ssh.session.disconnected")}</h2>
<bdi dir="auto" class="max-w-full break-all text-13-regular text-v2-text-text-muted">
{sshName(props.item.config)}
</bdi>
<p class="text-13-regular text-v2-text-text-muted">{language.t("ssh.session.reconnectDescription")}</p>
</div>
<Show when={props.item.error}>
{(error) => (
<p role="alert" class="max-w-sm text-13-regular text-v2-text-text-muted">
{language.t(`ssh.error.${error()}`)}
</p>
)}
</Show>
<Button variant="neutral" disabled={connecting()} aria-busy={connecting()} onClick={props.onReconnect}>
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{language.t(
connecting()
? "ssh.session.connecting"
: props.item.stage === "authentication"
? "ssh.action.authenticate"
: "ssh.session.reconnect",
)}
</Button>
</section>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup, untrack, type ParentProps } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { createSshController } from "./controller"
import { DialogSsh } from "./dialog"
import type { SshState } from "./types"
const key = ["platform", "sshServers"] as const
const context = createSimpleContext({
name: "Ssh",
init: () => {
const platform = usePlatform()
const client = useQueryClient()
const language = useLanguage()
const query = useQuery(() =>
queryOptions<SshState>({
queryKey: key,
queryFn: () => platform.sshServers?.getState() ?? Promise.resolve({ servers: [] }),
staleTime: Infinity,
}),
)
createEffect(() => {
const off = platform.sshServers?.subscribe((state) => client.setQueryData(key, state))
if (off) onCleanup(off)
})
return {
get servers() {
return query.data?.servers ?? []
},
get loading() {
return query.isLoading
},
...createSshController({
items: () => query.data?.servers ?? [],
api: platform.sshServers,
refresh: () => query.refetch({ throwOnError: true }),
error: () => showToast({ variant: "error", title: language.t("common.requestFailed") }),
}),
}
},
})
export const useSsh = () => context.use()
export function SshProvider(props: ParentProps) {
return (
<context.provider>
<SshDialogs />
{props.children}
</context.provider>
)
}
function SshDialogs() {
const ssh = useSsh()
// Capture an owner inside the SSH context, independent of transient rows and menus.
const dialog = useDialog()
createEffect(() => {
const item = ssh.dialog.next()
if (!item || dialog.active) return
ssh.dialog.opened(item.config.id)
untrack(() => void dialog.push(() => <DialogSsh config={item.config} promptOnly />))
})
return null
}
+153
View File
@@ -0,0 +1,153 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
import { isSshConnecting } from "./status"
export function createSshController(input: {
items: () => readonly SshItem[]
api: Pick<SshPlatform, "start" | "respond" | "cancel" | "disconnect" | "forget"> | undefined
refresh: () => Promise<unknown>
error: () => void
}) {
const [attempts, setAttempts] = createStore<
Record<
string,
| {
active: boolean
submitting: boolean
prompted: boolean
answered?: string
error: boolean
onConnected?: () => void
}
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
if (!attempt?.active) return
const onConnected = attempt.onConnected
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
})
createEffect(() => {
for (const item of input.items()) {
const attempt = attempts[item.config.id]
if (!attempt?.active || attempt.submitting) continue
if (
item.stage === "ready" ||
item.stage === "failed" ||
item.stage === "disconnected" ||
item.authenticatingElsewhere ||
(attempt.prompted && item.stage === "authentication" && !item.prompt)
) {
settle(item.config.id)
}
}
})
return {
item,
submitting: (id: string) => !!attempts[id]?.submitting,
error: (id: string) => !!attempts[id]?.error,
answered: (id: string) =>
!!item(id)?.prompt && !attempts[id]?.error && attempts[id]?.answered === item(id)?.prompt?.id,
pending: (id: string) =>
!!attempts[id]?.submitting ||
!!item(id)?.authenticatingElsewhere ||
isSshConnecting(item(id)?.stage ?? "disconnected"),
dialog: {
next: () =>
input.items().find((item) => {
const attempt = attempts[item.config.id]
return (
attempt?.active &&
!attempt.submitting &&
!attempt.prompted &&
!attempt.error &&
(item.prompt || item.stage === "incompatible")
)
}),
opened: (id: string) => setAttempts(id, "prompted", true),
},
connect: (config: SshConfig, options?: { dialog?: boolean; replace?: boolean; onConnected?: () => void }) => {
const api = input.api
if (!api || item(config.id)?.authenticatingElsewhere) return
if (
attempts[config.id]?.submitting ||
(attempts[config.id]?.active && !attempts[config.id]?.error && !options?.replace)
)
return
setAttempts(config.id, {
active: true,
submitting: true,
prompted: !!options?.dialog,
answered: undefined,
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
@@ -0,0 +1,289 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { onCleanup, onMount, Show } from "solid-js"
import { PlatformProvider } from "@/runtime/platform/platform"
import { SshProvider, useSsh } from "./context"
import { useSshAuthenticate } from "./authenticate"
import { HomeProjectsView } from "@/home/projects/view"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { DialogSsh } from "./dialog"
import type { SshItem, SshPlatform, SshState } from "./types"
import { SshConnectionPanel } from "./connection-panel"
import { SshServerSettings } from "./settings"
function Fixture(props: {
session?: boolean
settings?: boolean
incompatible?: boolean
keyOnly?: boolean
connectionDelay?: number
initial?: "connecting" | "password" | "confirmation" | "failure" | "required"
responseDelay?: number
}) {
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
step: 0,
item:
props.initial === "required"
? {
config: { id: "story", target: "ssh linuxbook", name: "" },
stage: props.session || props.settings ? "disconnected" : "authentication",
saved: true,
detail: "",
}
: undefined,
}
onCleanup(() => clearTimeout(state.timer))
const listeners = new Set<(state: SshState) => void>()
const snapshot = (): SshState => ({ servers: state.item ? [state.item] : [] })
const update = (changes: Partial<SshItem>) => {
if (!state.item) return
state.item = { ...state.item, ...changes }
listeners.forEach((listener) => listener(snapshot()))
}
const prompts = [
{
id: "host-key",
text: "The authenticity of host 'dev.example.com' can't be established.\nED25519 key fingerprint is SHA256:EXAMPLE-FINGERPRINT-FOR-STORY-ONLY.\nAre you sure you want to continue connecting?",
confirm: true,
},
{ id: "password", text: "brendon@dev.example.com's password:", confirm: false },
{ id: "otp", text: "Verification code:", confirm: false },
]
const api: SshPlatform = {
getState: async () => snapshot(),
subscribe(callback) {
listeners.add(callback)
return () => {
listeners.delete(callback)
}
},
hosts: async () => ["devbox", "staging", "build-host"],
start: async (input) => {
clearTimeout(state.timer)
state.before = state.item
state.step = props.initial === "password" || props.initial === "required" ? 1 : 0
state.item = {
config: input,
saved: props.initial === "required",
stage: "connecting",
detail: "",
destination: "brendon@dev.example.com:22",
}
if (props.initial === "connecting") return
if (props.incompatible && !input.replace) {
update({ stage: "incompatible", error: "version" })
return
}
if (props.connectionDelay) {
update({ stage: "connecting" })
state.timer = setTimeout(
() => update(props.keyOnly ? { stage: "ready" } : { stage: "authentication", prompt: prompts[state.step] }),
props.connectionDelay,
)
return
}
update(
props.initial === "failure"
? {
stage: "failed",
error: "connection",
detail: "ssh: connect to host dev.example.com port 22: Connection refused",
}
: { stage: "authentication", prompt: prompts[state.step] },
)
},
respond: async () => {
const next = () => {
state.step += 1
update(
state.step < prompts.length
? { stage: "authentication", prompt: prompts[state.step] }
: { stage: "ready", prompt: undefined },
)
}
if (!props.responseDelay) return next()
update({ stage: "connecting", prompt: undefined })
state.timer = setTimeout(next, props.responseDelay)
},
resolve: async () => null,
disconnect: async () => {
clearTimeout(state.timer)
update({ stage: "disconnected", prompt: undefined })
},
cancel: async () => {
if (state.item?.stage === "incompatible") return
clearTimeout(state.timer)
update({ ...state.before, stage: state.before?.stage ?? "disconnected", prompt: undefined })
},
forget: async () => {
state.item = undefined
listeners.forEach((listener) => listener(snapshot()))
},
openConfig: async () => {},
}
return (
<PlatformProvider
value={{
platform: "desktop",
windowID: "ssh-story",
sshServers: api,
openExternal() {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
}}
>
<QueryClientProvider client={new QueryClient()}>
<SshProvider>
{props.settings ? (
<AuthenticationSettings />
) : props.session ? (
<AuthenticationSession />
) : props.initial === "required" ? (
<AuthenticationHome />
) : (
<Open initial={props.initial} />
)}
</SshProvider>
</QueryClientProvider>
</PlatformProvider>
)
}
function AuthenticationSettings() {
return (
<div class="settings-servers" style={{ width: "min(100%, 480px)" }}>
<SshServerSettings
filter=""
domain={{
collection: { items: () => [], health: () => ({}) },
defaults: { available: () => false, key: () => null, set: async () => {} },
connection: {
canRemove: () => false,
remove: async () => {},
canHide: () => false,
isHidden: () => false,
setHidden: () => {},
},
}}
/>
</div>
)
}
function AuthenticationSession() {
const ssh = useSsh()
return (
<div style={{ height: "70vh" }}>
<Show when={ssh.servers[0]}>
{(item) => (
<Show when={item().stage !== "ready"} fallback={<div>Session connected</div>}>
<SshConnectionPanel
item={item()}
pending={ssh.pending(item().config.id)}
onReconnect={() => ssh.connect(item().config)}
/>
</Show>
)}
</Show>
</div>
)
}
function AuthenticationHome() {
const language = useLanguage()
const ssh = useSsh()
const authenticate = useSshAuthenticate()
const server: ServerConnection.Ssh = {
type: "ssh",
id: "story",
host: "linuxbook",
displayName: "linuxbook",
label: "SSH",
http: { url: "http://127.0.0.1:0" },
get authenticationRequired() {
return ssh.servers[0]?.stage === "authentication"
},
get connecting() {
return ssh.servers[0]?.stage === "connecting"
},
}
const projects = [{ worktree: "/home/user/project", expanded: true }]
return (
<div style={{ width: "min(100%, 340px)" }}>
<HomeProjectsView
dropdown
language={language}
servers={[server]}
projects={projects}
recentlyClosed={[]}
selection={{ server: ServerConnection.key(server) }}
homedir="/home/user"
serverHealth={() => ({ healthy: ssh.servers[0]?.stage === "ready" })}
projectsForServer={() => projects}
collapsed={() => false}
canDefaultServer={false}
defaultServerKey={null}
canRevealProject={() => false}
unseenCount={() => 0}
onWheel={() => {}}
onChooseProject={(server) => {
authenticate(server)
}}
onFocusServer={(server) => {
authenticate(server)
}}
onAuthenticateServer={(server) => {
authenticate(server)
}}
onToggleCollapsed={() => {}}
onEditServer={() => {}}
onSetDefaultServer={() => {}}
canRemoveServer={() => false}
onRemoveServer={() => {}}
canHideServer={() => false}
onHideServer={() => {}}
onMoveProject={() => {}}
onSelectProject={() => {}}
onAddProjects={() => {}}
onOpenProjectNewSession={() => {}}
canImportSession={false}
onImportSession={() => {}}
onEditProject={() => {}}
onRevealProject={() => {}}
onClearNotifications={() => {}}
onCloseProject={() => {}}
onOpenSettings={() => {}}
onOpenHelp={() => {}}
/>
</div>
)
}
function Open(props: { initial?: string }) {
const dialog = useDialog()
const open = () =>
dialog.show(() => (
<DialogSsh
config={props.initial ? { id: "story", target: "devbox", name: "Development" } : undefined}
connect={!!props.initial}
/>
))
onMount(open)
return <Button onClick={open}>Open SSH connection</Button>
}
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
export const Host = { render: () => <Fixture /> }
export const Connecting = { render: () => <Fixture initial="connecting" /> }
export const Password = { render: () => <Fixture initial="password" /> }
export const SlowPassword = { render: () => <Fixture initial="password" responseDelay={5000} /> }
export const Confirmation = { render: () => <Fixture initial="confirmation" /> }
export const Failure = { render: () => <Fixture initial="failure" /> }
+246
View File
@@ -0,0 +1,246 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { Divider } from "@opencode/ui/divider"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useSsh } from "./context"
import type { SshConfig, SshItem } from "./types"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
import "@/settings/settings.css"
import "./ssh.css"
export function useOpenSshProject() {
const servers = useServers()
const picker = useDirectoryPicker()
const tabs = useTabs()
const language = useLanguage()
return (id: string) => {
const server = servers.list.find((server) => server.type === "ssh" && server.id === id)
if (!server) return
picker({
server,
title: language.t("ssh.project", { host: server.displayName || (server.type === "ssh" ? server.host : "") }),
onSelect: (value) => {
const directory = Array.isArray(value) ? value[0] : value
if (!directory) return
const key = ServerConnection.key(server)
servers.projects.forServer(key).open(directory)
void tabs.newDraft({ server: key, directory })
},
})
}
}
export function DialogSsh(props: {
config?: SshConfig
connect?: boolean
promptOnly?: boolean
openProject?: boolean
onConnected?: () => void
}) {
const dialog = useDialog()
const language = useLanguage()
const ssh = useSsh()
// Entry-point behavior is fixed for the lifetime of this dialog. Settings
// connects without needing the project/tab contexts used by the palette.
const openProject = props.openProject ? useOpenSshProject() : undefined
const id = props.config?.id ?? crypto.randomUUID()
let cancelButton: HTMLButtonElement | undefined
const [state, setState] = createStore({
target: props.config?.target ?? "",
name: props.config?.name ?? "",
started: !!props.promptOnly,
prompted: !!props.promptOnly,
response: "",
complete: false,
})
const item = createMemo(() => ssh.item(id))
const error = createMemo(() => {
if (ssh.error(id)) return language.t("common.requestFailed")
const error = item()?.error
return error ? language.t(`ssh.error.${error}`) : undefined
})
const busy = createMemo(
() => ssh.submitting(id) || (state.started && !ssh.error(id) && isSshConnecting(item()?.stage ?? "connecting")),
)
const prompt = createMemo<SshItem["prompt"]>((previous) => item()?.prompt ?? (busy() ? previous : undefined))
const waiting = () => busy() || ssh.answered(id)
const start = (replace = false) => {
if (busy() || !state.target.trim()) return
setState({ started: true, prompted: !!prompt() })
ssh.connect({ id, target: state.target, name: state.name }, { dialog: true, replace })
}
const respond = () => {
const current = item()?.prompt
if (!current || waiting() || (!current.confirm && !state.response)) return
ssh.respond(id, current.id, current.confirm ? "yes" : state.response)
}
createEffect(() => {
prompt()?.id
setState("response", "")
if (prompt()) setState("prompted", true)
// Never let a focused Continue button become Trust between SSH challenges.
if (prompt()?.confirm) queueMicrotask(() => cancelButton?.focus())
})
createEffect(() => {
if (!state.started || item()?.stage !== "ready" || ssh.submitting(id) || state.complete) return
setState("complete", true)
dialog.close()
if (openProject) queueMicrotask(() => openProject(id))
if (props.onConnected) queueMicrotask(props.onConnected)
})
onMount(() => {
if (props.connect) start()
})
onCleanup(() => {
if (state.started && !state.complete) ssh.cancel(id)
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
if (prompt()) return
start(item()?.stage === "incompatible")
}
return (
<Dialog fit class="settings-server-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>
{state.prompted || props.config
? language.t("ssh.connectTo", { host: sshName(state) })
: language.t("ssh.add")}
</DialogTitle>
</DialogHeader>
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-target">
{language.t("ssh.target")}
</label>
<TextInput
id="ssh-target"
type="text"
appearance="large"
class="!w-full self-stretch"
dir="ltr"
value={state.target}
autofocus
placeholder={language.t("ssh.placeholder")}
spellcheck={false}
autocomplete="off"
disabled={busy() || !!prompt()}
invalid={!!error()}
onInput={(event) => setState("target", event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-name">
{language.t("dialog.server.add.name")}
</label>
<TextInput
id="ssh-name"
type="text"
appearance="large"
class="!w-full self-stretch"
dir="auto"
value={state.name}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={busy() || !!prompt()}
onInput={(event) => setState("name", event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</Show>
<Show when={prompt()} keyed>
{(prompt) => (
<div class="flex w-full min-w-0 flex-col gap-2">
<pre id="ssh-prompt" class="ssh-prompt" dir="auto">
{prompt.text}
</pre>
<Show when={!prompt.confirm}>
<TextInput
id="ssh-response"
aria-labelledby="ssh-prompt"
ref={(element) =>
queueMicrotask(() => {
if (element.isConnected) element.focus()
})
}
type="password"
appearance="large"
class="!w-full self-stretch"
autofocus
value={state.response}
autocomplete="off"
spellcheck={false}
disabled={waiting()}
onInput={(event) => setState("response", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
respond()
}
}}
/>
</Show>
</div>
)}
</Show>
<Show when={error()}>
{(error) => (
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
{error()}
</span>
)}
</Show>
</div>
</DialogBody>
<DialogFooter>
<Button
ref={(element: HTMLButtonElement) => {
cancelButton = element
}}
variant="neutral"
onClick={() => dialog.close()}
>
{language.t("common.cancel")}
</Button>
<Show
when={prompt()}
fallback={
<Button
variant="contrast"
disabled={busy() || !state.target.trim()}
onClick={() => start(item()?.stage === "incompatible")}
>
{busy()
? language.t("ssh.stage.connecting")
: item()?.stage === "incompatible"
? language.t("ssh.update")
: props.config
? language.t("ssh.connect")
: language.t("dialog.server.add.button")}
</Button>
}
>
{(prompt) => (
<Button variant="contrast" disabled={waiting() || (!prompt().confirm && !state.response)} onClick={respond}>
{waiting()
? language.t("ssh.stage.connecting")
: language.t(prompt().confirm ? "ssh.trust" : "ssh.continue")}
</Button>
)}
</Show>
</DialogFooter>
</Dialog>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { ServerActionsController } from "@/servers/registry/controller"
import { ServerConnection } from "@/runtime/server/registry"
import { useSsh } from "./context"
export function SshMenu(props: { id: string; domain: ServerActionsController }) {
const ssh = useSsh()
const language = useLanguage()
const item = () => ssh.item(props.id)
const key = () => ServerConnection.Key.make(`ssh:${props.id}`)
return (
<Show when={item()}>
{(item) => (
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Group>
<Menu.GroupLabel>{language.t("ssh.server.menu.label")}</Menu.GroupLabel>
<Show when={item().stage !== "ready"}>
<Menu.Item disabled={ssh.pending(props.id)} onSelect={() => ssh.connect(item().config)}>
{language.t(item().stage === "authentication" ? "ssh.authenticate" : "ssh.connect")}
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key()}>
<Menu.Item onSelect={() => props.domain.defaults.set(key())}>
{language.t("dialog.server.menu.default")}
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key()}>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item onSelect={() => void props.domain.connection.remove(key())}>
{language.t("dialog.server.menu.delete")}
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test"
import { sshHostname, sshName } from "./name"
test.each([
["brendan-box.exe.xyz", "brendan-box.exe.xyz"],
["ssh brendan-box.exe.xyz", "brendan-box.exe.xyz"],
["ssh anomaly@brendan-box.exe.xyz", "brendan-box.exe.xyz"],
['ssh -p 2222 -i "/keys/my key" -J jump@example.com anomaly@devbox', "devbox"],
["ssh -o 'ProxyCommand=ssh jump -W %h:%p' 'anomaly@devbox'", "devbox"],
[" ssh 'brendan-'box.exe.xyz ", "brendan-box.exe.xyz"],
["ssh user@[2001:db8::1]", "[2001:db8::1]"],
["", ""],
])("uses the hostname from %s", (target, hostname) => {
expect(sshHostname(target)).toBe(hostname)
expect(sshName({ target, name: "" })).toBe(hostname)
})
test("preserves custom names and the original command", () => {
const config = { name: "Development", target: "ssh -p 2222 anomaly@devbox" }
expect(sshName(config)).toBe("Development")
expect(config.target).toBe("ssh -p 2222 anomaly@devbox")
})
+16
View File
@@ -0,0 +1,16 @@
import type { SshConfig } from "./types"
export function sshHostname(target: string) {
// Accepted SSH targets end with a hostname or user@hostname, never a remote
// command. Strip shell quoting for display only; keep the saved target intact.
return (
(target.trim().split(/\s+/).at(-1) ?? "")
.replace(/["'\\]/g, "")
.split("@")
.at(-1) ?? ""
)
}
export function sshName(config: Pick<SshConfig, "name" | "target">) {
return config.name || sshHostname(config.target)
}
@@ -0,0 +1,17 @@
import { createEffect } from "solid-js"
import type { SshStart, SshState } from "./types"
export function createSshRestore(input: {
state: () => SshState | undefined
start: (input: SshStart) => Promise<void> | undefined
}) {
const restored = new Set<string>()
createEffect(() => {
for (const item of input.state()?.servers ?? []) {
if (!item.saved || restored.has(item.config.id)) continue
// Mark active connections too, so a later manual disconnect is respected.
restored.add(item.config.id)
if (item.stage === "disconnected") void input.start({ ...item.config, background: true })
}
})
}
+11
View File
@@ -0,0 +1,11 @@
import { useSsh } from "./context"
import { createSshRestore } from "./restore-state"
export function SshRestore() {
const ssh = useSsh()
createSshRestore({
state: () => ({ servers: ssh.servers }),
start: ssh.restore,
})
return null
}
+87
View File
@@ -0,0 +1,87 @@
import { For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { ServerCollectionController } from "@/servers/registry/controller"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { ServerConnection } from "@/runtime/server/registry"
import { useSsh } from "./context"
import { SshMenu } from "./menu"
import { Badge } from "@opencode/ui/badge"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
const ssh = useSsh()
const language = useLanguage()
return (
<For
each={ssh.servers.filter(
(item) =>
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
)}
>
{(item) => {
const key = ServerConnection.Key.make(`ssh:${item.config.id}`)
const health = () => props.domain.collection.health()[key]
const indicator = () => {
if (item.stage === "ready") return health() ?? { healthy: true }
if (item.stage === "incompatible") return { healthy: false, incompatible: true }
if (item.stage === "failed") return { healthy: false }
return undefined
}
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator
health={indicator()}
connecting={isSshConnecting(item.stage)}
authenticationRequired={item.stage === "authentication"}
/>
<div class="settings-servers-copy">
<span class="flex min-w-0 items-center gap-1">
<bdi class="settings-servers-name truncate" dir={item.config.name ? "auto" : "ltr"}>
{sshName(item.config)}
</bdi>
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{language.t("ssh.label")}
</span>
</span>
<Show
when={item.stage === "authentication"}
fallback={
<Show when={health()?.version}>
{(version) => <span class="settings-servers-meta">v{version()}</span>}
</Show>
}
>
<span class="settings-servers-meta">{language.t("ssh.stage.authentication")}</span>
</Show>
</div>
</div>
<div class="settings-servers-actions">
<Show when={item.stage === "authentication" || ssh.pending(item.config.id)}>
<Button
size="small"
variant="ghost-muted"
disabled={ssh.pending(item.config.id)}
aria-busy={ssh.pending(item.config.id)}
onClick={() => ssh.connect(item.config)}
>
<Show when={ssh.pending(item.config.id)}>
<Spinner class="size-3.5" />
</Show>
{language.t(ssh.pending(item.config.id) ? "ssh.session.connecting" : "ssh.action.authenticate")}
</Button>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<SshMenu id={item.config.id} domain={props.domain} />
</div>
</div>
)
}}
</For>
)
}
+10
View File
@@ -0,0 +1,10 @@
.ssh-prompt {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--font-family-mono);
font-size: 12px;
line-height: var(--line-height-compact);
max-height: 200px;
overflow: auto;
user-select: text;
}
+11
View File
@@ -0,0 +1,11 @@
import type { SshItem } from "./types"
export function isSshConnecting(stage: SshItem["stage"]) {
return (
stage === "connecting" ||
stage === "checking" ||
stage === "downloading" ||
stage === "uploading" ||
stage === "starting"
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
hosts(): Promise<readonly string[]>
start(input: SshStart): Promise<void>
resolve(id: string): Promise<SshHttp | null>
respond(id: string, prompt: string, value: string): Promise<void>
disconnect(id: string): Promise<void>
cancel(id: string): Promise<void>
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
+8 -2
View File
@@ -16,6 +16,7 @@ import { showToast } from "@/shell/notifications/toast"
import { DialogAddWslServer } from "./dialog"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
import { DialogSsh } from "../ssh/dialog"
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
@@ -30,7 +31,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
}
return (
<Show
when={platform.wslServers}
when={platform.wslServers || platform.sshServers}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
@@ -44,7 +45,12 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
<Show when={platform.sshServers}>
<Menu.Item onSelect={() => void dialog.push(() => <DialogSsh />)}>{language.t("ssh.add")}</Menu.Item>
</Show>
<Show when={platform.wslServers}>
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
+17 -1
View File
@@ -4,6 +4,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import { useCommand } from "@/shell/commands/command"
import type { SessionModel } from "../model"
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
import { useBrowserAttachments } from "./attachments"
@@ -12,6 +13,7 @@ export function createSessionBrowser(session: SessionModel) {
const attachments = useBrowserAttachments()
const language = useLanguage()
const server = useServer()
const commands = useCommand()
const [local, setLocal] = createStore({ error: undefined as string | undefined })
const attachment = () => {
const sessionID = session.identity.sessionID()
@@ -47,6 +49,20 @@ export function createSessionBrowser(session: SessionModel) {
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
})
}
const open = () => {
if (!available()) return
command({ type: "tabs.open" })
}
commands.register("session.browser", () => [
{
id: "browser.open",
title: language.t("command.browser.open"),
category: language.t("command.category.view"),
keybind: "mod+shift+b",
disabled: !available(),
onSelect: open,
},
])
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -109,7 +125,7 @@ export function createSessionBrowser(session: SessionModel) {
error: () => local.error ?? attachment()?.error,
registration: () => attachment()?.registration,
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open: () => command({ type: "tabs.open" }),
open,
command,
}
}
@@ -228,6 +228,7 @@ export function SessionSidePanel(props: {
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
createEffect(() => {
if (!file.ready()) return
@@ -465,7 +466,7 @@ export function SessionSidePanel(props: {
placement="bottom"
class="flex items-center"
>
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu appearance="standard" modal={false} placement="bottom-start" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
@@ -479,6 +480,7 @@ export function SessionSidePanel(props: {
<Menu.Portal>
<Menu.Content>
<Menu.Item
class="!gap-6"
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
@@ -491,7 +493,15 @@ export function SessionSidePanel(props: {
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Menu.Item onSelect={props.browser.open}>
<Menu.Item
class="!gap-6"
onSelect={props.browser.open}
shortcut={
<Show when={openBrowserKeybind().length > 0}>
<Keybind keys={openBrowserKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
@@ -45,7 +45,7 @@ export function SessionPermissionDock(props: {
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="contrast" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
<Button variant="submit" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
@@ -523,7 +523,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
</Button>
</Show>
<Button
variant={last() ? "contrast" : "neutral"}
variant={last() ? "submit" : "neutral"}
size="large"
disabled={sending()}
onClick={next}
@@ -69,7 +69,7 @@ export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSu
</Button>
</Show>
<Button
variant="neutral"
variant="submit"
size="small"
onClick={() => {
const selected = props.model.selected()
@@ -1,3 +1,4 @@
import { DataProvider } from "@opencode/session-ui/context"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
@@ -30,7 +31,13 @@ export const InlineMoveHint = {
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
<DataProvider
data={{ session: [], session_status: {}, session_diff: {} }}
directory="/project"
onSessionHref={(id) => `#${id}`}
>
<BackgroundWorkSummary tasks={tasks} />
</DataProvider>
</div>
),
}
@@ -1,7 +1,9 @@
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode/session-ui/actions"
import { useData } from "@opencode/session-ui/context"
import { Button } from "@opencode/ui/button"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
@@ -16,7 +18,7 @@ import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
@@ -70,6 +72,7 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [open, setOpen] = createSignal(false)
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
@@ -106,10 +109,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon
name="outline-arrow-to-corner-top-right"
class="shrink-0 text-v2-icon-icon-muted"
/>
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
@@ -125,13 +125,26 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<div
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setOpen(false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
</Dynamic>
)}
</For>
</Popover.Content>
@@ -389,8 +402,8 @@ function MessageTimelineView(
},
) {
const language = useLanguage()
const data = useData()
const server = useServer()
const data = server.ctx.data
const settings = useSettings()
const sdk = useWorkspaceLocation()
const sessionID = props.data.sessionID
@@ -13,6 +13,8 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { SettingsList } from "@/settings/list"
import { SshServerSettings } from "@/servers/ssh/settings"
import { useSsh } from "@/servers/ssh/context"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import "@/settings/settings.css"
@@ -22,13 +24,14 @@ export const SettingsServers: Component = () => {
const controller = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const ssh = useSsh()
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.collection.items().filter((item) => !isWslServer(item))
const items = controller.collection.items().filter((item) => !isWslServer(item) && item.type !== "ssh")
const query = store.filter.trim()
if (!query) return items
return fuzzysort
@@ -89,7 +92,7 @@ export const SettingsServers: Component = () => {
<div class="settings-tab-body settings-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
fallback={
<div class="settings-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
@@ -100,6 +103,7 @@ export const SettingsServers: Component = () => {
}
>
<SettingsList>
<SshServerSettings filter={store.filter} domain={controller} />
<WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
@@ -1,14 +1,24 @@
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
export function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.sshServers)
commands.push({
id: "server.ssh.add",
title: language.t("ssh.add"),
category: language.t("command.category.server"),
onSelect: () => void dialog.push(() => <DialogSsh openProject />),
})
if (platform.platform !== "desktop" || !platform.exportDebugLogs) return commands
commands.push({
id: "logs.export",
+3 -2
View File
@@ -8,6 +8,7 @@ import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { SshAuthentication } from "@/servers/ssh/authentication"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -97,9 +98,9 @@ export default function Layout(props: ParentProps) {
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<SshAuthentication>
<Suspense>{props.children}</Suspense>
</div>
</SshAuthentication>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
+37 -13
View File
@@ -679,8 +679,11 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col gap-2">
<TitlebarRightMount vertical />
<Show when={updateState().visible}>
<TitlebarUpdateIconButton state={updateState()} vertical />
</Show>
</div>
</Portal>
)}
@@ -691,7 +694,9 @@ export function Titlebar(props: {
<Show when={!mobile()}>
<div class="flex-1" />
</Show>
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
<Show when={!props.verticalTabs}>
<TitlebarRight state={rightState()} />
</Show>
</div>
)
}}
@@ -714,34 +719,52 @@ type TitlebarRightState = {
update: TitlebarUpdatePillState
}
function TitlebarRight(props: { state: TitlebarRightState; mount?: boolean }) {
function TitlebarRight(props: { state: TitlebarRightState }) {
return (
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<Show when={props.mount !== false}>
<TitlebarRightMount />
</Show>
<TitlebarRightMount />
</div>
)
}
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState; vertical?: boolean }) {
const label = () => (
<span
class="shrink-0 text-[11px] leading-4 text-v2-text-text-accent [font-weight:530] opacity-0 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"
classList={{
"ms-px me-4 -translate-x-2 rtl:translate-x-2": props.vertical,
"ms-2 me-px translate-x-2 rtl:-translate-x-2": !props.vertical,
}}
>
{props.state.label}
</span>
)
return (
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none">
<div
data-slot="titlebar-update"
class="group relative shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 focus-within:z-30 motion-reduce:transition-none"
classList={{
"h-7 w-7 self-start hover:w-[84px] focus-within:w-[84px]": props.vertical,
"me-3 h-5 w-5 hover:w-[68px] focus-within:w-[68px]": !props.vertical,
}}
>
<button
type="button"
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
class="absolute top-0 z-10 flex h-full w-full items-center overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[background-color] duration-150 ease-out group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none [app-region:no-drag]"
classList={{ "start-0 justify-start": props.vertical, "end-0 justify-end": !props.vertical }}
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-busy={props.state.installing}
aria-label={props.state.ariaLabel}
>
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
{props.state.label}
</span>
<span class="flex size-5 shrink-0 items-center justify-center">
<Show when={!props.vertical}>{label()}</Show>
<span
class="flex shrink-0 items-center justify-center"
classList={{ "size-7": props.vertical, "size-5": !props.vertical }}
>
<Show
when={!props.state.installing}
fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}
@@ -751,6 +774,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
</svg>
</Show>
</span>
<Show when={props.vertical}>{label()}</Show>
</button>
</div>
)
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSshAuthentication } from "../src/servers/ssh/authentication-state"
import type { SshItem } from "../src/servers/ssh/types"
test("background authentication stays quiet; selecting a tab prompts once and cancellation is respected", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ selection?: string; busy: boolean; item: SshItem }>({
busy: false,
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "authentication", saved: true, detail: "" },
})
createSshAuthentication({
selection: () => state.selection,
item: () => state.item,
busy: () => state.busy,
open: (item) => {
opened.push(item.config.id)
setState("busy", true)
},
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("selection", "session-1")
expect(opened).toEqual(["host"])
fixture.setState("busy", false)
fixture.setState("item", "detail", "new status")
expect(opened).toHaveLength(1)
fixture.setState("selection", undefined)
fixture.setState("selection", "session-1")
expect(opened).toHaveLength(2)
} finally {
fixture.dispose()
}
})
test("a selected tab waits for authentication and other dialogs before offering a prompt", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ busy: boolean; item: SshItem }>({
busy: true,
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "connecting", saved: true, detail: "" },
})
createSshAuthentication({
selection: () => "draft-1",
item: () => state.item,
busy: () => state.busy,
open: (item) => {
opened.push(item.config.id)
},
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("item", "stage", "authentication")
expect(opened).toEqual([])
fixture.setState("busy", false)
expect(opened).toEqual(["host"])
fixture.setState("item", "stage", "connecting")
fixture.setState("item", "stage", "authentication")
expect(opened).toHaveLength(1)
} finally {
fixture.dispose()
}
})
test("selecting a tab waits for another window to release authentication", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ item: SshItem }>({
item: {
config: { id: "host", target: "linuxbook", name: "" },
stage: "authentication",
authenticatingElsewhere: true,
saved: true,
detail: "",
},
})
createSshAuthentication({
selection: () => "session-1",
item: () => state.item,
busy: () => false,
open: (item) => opened.push(item.config.id),
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("item", "detail", "still waiting in another window")
expect(opened).toEqual([])
fixture.setState("item", "authenticatingElsewhere", false)
expect(opened).toEqual(["host"])
} finally {
fixture.dispose()
}
})
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createComposerEditor } from "../src/composer/editor/interaction"
import type { ComposerPersistedState } from "../src/composer/types"
test("a disconnected composer preserves text and ignores submissions until reconnect", () => {
createRoot((dispose) => {
const [state, setState] = createStore({ connected: false, submissions: 0 })
const store = createStore<ComposerPersistedState>({
prompt: [{ type: "text", content: "keep my draft", start: 0, end: 13 }],
context: { items: [] },
})
const editor = createComposerEditor({
store,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
view: {
submit: {
available: () => state.connected,
stopping: () => false,
onStop() {},
onSubmit: () => setState("submissions", (count) => count + 1),
},
},
})
expect(editor.canSubmit()).toBe(false)
editor.submit()
expect(state.submissions).toBe(0)
expect(editor.value()).toBe("keep my draft")
setState("connected", true)
expect(editor.canSubmit()).toBe(true)
editor.submit()
expect(state.submissions).toBe(1)
dispose()
})
})
@@ -0,0 +1,257 @@
import { expect, test } from "bun:test"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSshController } from "../src/servers/ssh/controller"
import type { SshItem } from "../src/servers/ssh/types"
function fixture() {
return createRoot((dispose) => {
const config = { id: "host", target: "ssh linuxbook", name: "" }
const [state, setState] = createStore<{ items: SshItem[]; busy: boolean }>({
items: [{ config, stage: "disconnected", saved: true, detail: "" }],
busy: false,
})
const calls = { starts: 0, responses: 0, cancels: 0, forgets: 0, prompts: 0, errors: 0, connected: 0 }
const admission = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<void>()
const response = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const ssh = createSshController({
items: () => state.items,
api: {
start: () => {
calls.starts++
return admission.promise
},
respond: () => {
calls.responses++
return calls.responses === 1 ? response.promise : Promise.resolve()
},
cancel: async () => {
calls.cancels++
setState("items", 0, { stage: "disconnected", prompt: undefined })
cancelled.resolve()
},
forget: async () => {
calls.forgets++
setState("items", [])
},
disconnect: async () => {},
},
refresh: () => refresh.promise,
error: () => calls.errors++,
})
createEffect(() => {
const item = ssh.dialog.next()
if (!item || state.busy) return
ssh.dialog.opened(item.config.id)
calls.prompts++
})
const admitted = async () => {
admission.resolve()
refresh.resolve()
await refresh.promise
await Promise.resolve()
await Promise.resolve()
}
return { dispose, config, setState, calls, admission, refresh, response, cancelled, admitted, ssh }
})
}
test("key-based reconnect stays pending through admission and refetch without opening a dialog", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.ssh.connect(app.config)
expect(app.ssh.pending("host")).toBe(true)
app.admission.resolve()
await app.admission.promise
await Promise.resolve()
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.starts).toBe(1)
app.setState("items", 0, "stage", "connecting")
await app.admitted()
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.prompts).toBe(0)
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
expect(app.ssh.pending("host")).toBe(false)
expect(app.calls.connected).toBe(1)
} finally {
app.dispose()
}
})
test("reconnect waits for an available dialog and opens only once across SSH challenges", async () => {
const app = fixture()
try {
app.setState("busy", true)
app.ssh.connect(app.config)
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
expect(app.calls.prompts).toBe(0)
app.setState("busy", false)
expect(app.calls.prompts).toBe(1)
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
expect(app.calls.prompts).toBe(1)
expect(app.calls.starts).toBe(1)
} finally {
app.dispose()
}
})
test("a connection form owns its challenges and reports request failures inline", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { dialog: true })
app.admission.reject(new Error("IPC unavailable"))
await app.admission.promise.catch(() => {})
await Promise.resolve()
expect(app.ssh.error("host")).toBe(true)
expect(app.ssh.submitting("host")).toBe(false)
expect(app.calls.errors).toBe(0)
expect(app.calls.prompts).toBe(0)
} finally {
app.dispose()
}
})
test("responding suppresses duplicate submissions while allowing the next SSH challenge", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
app.ssh.respond("host", "expired", "ignored")
app.ssh.respond("host", "password", "secret")
app.ssh.respond("host", "password", "secret")
expect(app.calls.responses).toBe(1)
expect(app.ssh.submitting("host")).toBe(true)
app.response.resolve()
await app.response.promise
await Promise.resolve()
expect(app.ssh.answered("host")).toBe(true)
app.ssh.respond("host", "password", "secret")
expect(app.calls.responses).toBe(1)
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
expect(app.ssh.answered("host")).toBe(false)
app.ssh.respond("host", "otp", "123456")
expect(app.calls.responses).toBe(2)
} finally {
app.dispose()
}
})
test("failed reconnect becomes retryable without opening a connection form", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "connecting")
await app.admitted()
app.setState("items", 0, "stage", "failed")
expect(app.ssh.pending("host")).toBe(false)
expect(app.calls.prompts).toBe(0)
app.ssh.connect(app.config)
expect(app.calls.starts).toBe(2)
} finally {
app.dispose()
}
})
test("a failed response can be retried without losing the reconnect continuation", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
app.ssh.respond("host", "password", "secret")
app.response.reject(new Error("IPC unavailable"))
await app.response.promise.catch(() => {})
await Promise.resolve()
expect(app.ssh.error("host")).toBe(true)
expect(app.ssh.answered("host")).toBe(false)
expect(app.calls.errors).toBe(0)
app.ssh.respond("host", "password", "secret")
await Promise.resolve()
expect(app.calls.responses).toBe(2)
expect(app.ssh.error("host")).toBe(false)
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
expect(app.calls.connected).toBe(1)
} finally {
app.dispose()
}
})
test("cancelling a version-mismatch dialog allows another reconnect", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
expect(app.calls.prompts).toBe(1)
app.ssh.cancel("host")
await app.cancelled.promise
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
expect(app.calls.starts).toBe(2)
expect(app.calls.prompts).toBe(2)
expect(app.calls.forgets).toBe(0)
} finally {
app.dispose()
}
})
test("updating from the authentication dialog preserves the continuation and invokes it once", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
app.ssh.connect(app.config, { dialog: true, replace: true })
app.setState("items", 0, "stage", "connecting")
await app.admitted()
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
app.setState("items", 0, "detail", "updated")
await Promise.resolve()
expect(app.calls.connected).toBe(1)
expect(app.calls.prompts).toBe(1)
} finally {
app.dispose()
}
})
test("cancelling an unsaved connection interrupts admission and forgets it", async () => {
const app = fixture()
try {
app.setState("items", 0, "saved", false)
app.ssh.connect(app.config, { dialog: true })
app.ssh.cancel("host")
await app.cancelled.promise
await Promise.resolve()
expect(app.calls.cancels).toBe(1)
expect(app.calls.forgets).toBe(1)
expect(app.ssh.item("host")).toBeUndefined()
expect(app.ssh.submitting("host")).toBe(false)
app.admission.resolve()
} finally {
app.dispose()
}
})
test("another window's authentication stays pending without starting a competing attempt", () => {
const app = fixture()
try {
app.setState("items", 0, { stage: "authentication", authenticatingElsewhere: true })
app.ssh.connect(app.config)
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.starts).toBe(0)
app.setState("items", 0, "authenticatingElsewhere", false)
app.ssh.connect(app.config)
expect(app.calls.starts).toBe(1)
} finally {
app.dispose()
}
})
@@ -0,0 +1,130 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerHealth, type ServerHealth } from "../src/runtime/server/health"
import { ServerConnection } from "../src/runtime/server/registry"
import type { SshItem } from "../src/servers/ssh/types"
test("SSH health is checked only with an active tunnel, and cancellation clears stale failures", async () => {
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
const app = createRoot((dispose) => {
const [state, setState] = createStore<{ stage: SshItem["stage"] }>({ stage: "disconnected" })
const connection: ServerConnection.Ssh = {
type: "ssh",
id: "fixture",
host: "devbox",
http: { url: "http://127.0.0.1:12345" },
get stage() {
return state.stage
},
}
const health = createServerHealth(
() => [connection],
() => true,
() => {
const request = Promise.withResolvers<ServerHealth>()
requests.push(request)
return request.promise
},
)
return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
})
try {
expect(app.health()).toBeUndefined()
app.setState("stage", "connecting")
app.setState("stage", "authentication")
app.setState("stage", "disconnected")
expect(requests).toHaveLength(0)
expect(app.health()).toBeUndefined()
app.setState("stage", "ready")
expect(requests).toHaveLength(1)
expect(app.health()?.checking).toBe(true)
app.setState("stage", "authentication")
requests[0]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toBeUndefined()
app.setState("stage", "failed")
expect(app.health()?.healthy).toBe(false)
app.setState("stage", "authentication")
expect(app.health()).toBeUndefined()
app.setState("stage", "ready")
requests[1]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: false })
} finally {
app.dispose()
}
})
function fixture() {
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
return createRoot((dispose) => {
const [state, setState] = createStore({ url: "http://127.0.0.1:0", connecting: true })
const connection: ServerConnection.Ssh = {
type: "ssh",
id: "fixture",
host: "devbox",
get http() {
return { url: state.url }
},
get connecting() {
return state.connecting
},
}
const health = createServerHealth(
() => [connection],
() => true,
() => {
const request = Promise.withResolvers<ServerHealth>()
requests.push(request)
return request.promise
},
)
return { dispose, setState, requests, health: () => health[ServerConnection.key(connection)] }
})
}
test("a new SSH endpoint stays checking after connection completes instead of showing the old failure", async () => {
const app = fixture()
try {
app.requests[0]?.resolve({ healthy: false })
await Promise.resolve()
await Promise.resolve()
expect(app.health()?.healthy).toBe(false)
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
expect(app.health()).toEqual({ healthy: false, checking: true })
app.requests[1]?.resolve({ healthy: true, version: "2.0.0" })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: true, version: "2.0.0" })
} finally {
app.dispose()
}
})
test("a late failure from the old endpoint cannot overwrite the new endpoint check", async () => {
const app = fixture()
try {
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
app.requests[0]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: false, checking: true })
app.requests[1]?.resolve({ healthy: true })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: true })
} finally {
app.dispose()
}
})
test("a failed check of the new tunnel stops checking and still reports failure", async () => {
const app = fixture()
try {
app.setState({ url: "http://127.0.0.1:12345", connecting: false })
expect(app.health()?.checking).toBe(true)
app.requests[1]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: false })
} finally {
app.dispose()
}
})
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSshRestore } from "../src/servers/ssh/restore-state"
import type { SshItem, SshStart, SshState } from "../src/servers/ssh/types"
const server = (id: string, stage: SshItem["stage"] = "disconnected", saved = true): SshItem => ({
config: { id, target: `ssh ${id}`, name: "" },
saved,
stage,
detail: "",
})
test("restores every saved server after loading without tabs or a default server", () => {
const starts: SshStart[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ current?: SshState }>({})
createSshRestore({
state: () => state.current,
start: (input) => {
starts.push(input)
return Promise.resolve()
},
})
return { dispose, setState }
})
try {
expect(starts).toEqual([])
fixture.setState("current", {
servers: [server("devbox"), server("buildbox"), server("draft", "disconnected", false)],
})
expect(starts).toEqual([
{ ...server("devbox").config, background: true },
{ ...server("buildbox").config, background: true },
])
fixture.setState("current", "servers", 0, "stage", "connecting")
fixture.setState("current", "servers", 0, "stage", "disconnected")
expect(starts).toHaveLength(2)
} finally {
fixture.dispose()
}
})
test("does not restart active connections or authentication prompts after state updates", () => {
const starts: SshStart[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<SshState>({
servers: [server("ready", "ready"), server("busy", "connecting"), server("prompt", "authentication")],
})
createSshRestore({
state: () => state,
start: (input) => {
starts.push(input)
return Promise.resolve()
},
})
return { dispose, setState }
})
try {
expect(starts).toEqual([])
fixture.setState("servers", 0, "stage", "disconnected")
fixture.setState("servers", 1, "stage", "failed")
fixture.setState("servers", 2, "stage", "disconnected")
expect(starts).toEqual([])
} finally {
fixture.dispose()
}
})
+44 -34
View File
@@ -98,17 +98,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("console", {
description: "Manage OpenCode Console access",
commands: [
Spec.make("login", {
description: "Log in to OpenCode Console",
params: {
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
},
}),
],
}),
Spec.make("auth", {
description: "manage AI providers and credentials",
commands: [
@@ -134,13 +123,31 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
},
}),
Spec.make("logout", {
description: "log out from a configured provider",
description: "log out of a saved account",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
credential: Argument.string("credential").pipe(
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
Argument.optional,
),
},
}),
Spec.make("switch", {
description: "switch the active account for an integration",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
credential: Argument.string("credential").pipe(
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
Argument.optional,
),
},
}),
],
@@ -261,28 +268,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
},
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
...ServerParams,
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
),
},
}),
Spec.make("import", {
description: "Import session data from a JSON file or URL",
params: {
...ServerParams,
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
directory: Flag.string("directory").pipe(
Flag.withDescription("Directory in which to import the session"),
Flag.optional,
),
},
}),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -387,6 +372,31 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
},
}),
Spec.make("export", {
description: "Export session data as JSON",
params: {
...ServerParams,
session: Argument.string("session").pipe(
Argument.withDescription("Session ID to export"),
Argument.optional,
),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
),
},
}),
Spec.make("import", {
description: "Import session data from a JSON file or URL",
params: {
...ServerParams,
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
directory: Flag.string("directory").pipe(
Flag.withDescription("Directory in which to import the session"),
Flag.optional,
),
},
}),
],
}),
Spec.make("service", {
@@ -0,0 +1,74 @@
import { autocomplete } from "@clack/prompts"
import { Effect } from "effect"
import type { IntegrationInfo } from "@opencode/client"
import { prompt } from "../../../ui/prompt"
import { resolveIntegration } from "./shared"
export const chooseIntegration = Effect.fn("cli.auth.account.integration")(function* (
integrations: IntegrationInfo[],
target?: string,
) {
if (target) return yield* resolveIntegration(integrations, target)
const configured = integrations.filter((integration) =>
integration.connections.some((connection) => connection.type === "credential"),
)
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: configured.map((integration) => ({
value: integration.id,
label: integration.name,
hint: integration.connections
.filter((connection) => connection.type === "credential")
.map((connection) => connection.label)
.join(", "),
})),
}),
)
return yield* resolveIntegration(configured, id)
})
export const chooseCredential = Effect.fn("cli.auth.account.credential")(function* (
integration: IntegrationInfo,
action: "log out" | "switch to",
target?: string,
) {
const credentials = integration.connections.filter((connection) => connection.type === "credential")
if (credentials.length === 0) {
const environment = integration.connections
.filter((connection) => connection.type === "env")
.map((connection) => connection.name)
if (environment.length && action === "log out") {
yield* Effect.fail(
new Error(
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
),
)
}
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
}
if (target) {
const byID = credentials.find((credential) => credential.id === target)
if (byID) return byID.id
const matches = credentials.filter((credential) => credential.label.toLowerCase() === target.toLowerCase())
if (matches.length === 1) return matches[0].id
if (matches.length > 1)
return yield* Effect.fail(
new Error(`Credential label "${target}" is ambiguous. Use an ID: ${matches.map((item) => item.id).join(", ")}`),
)
return yield* Effect.fail(new Error(`Credential not found for ${integration.name}: ${target}`))
}
return yield* prompt<string>(() =>
autocomplete({
message: `Select ${integration.name} account to ${action}`,
maxItems: 8,
options: credentials.map((credential, index) => ({
value: credential.id,
label: index === 0 ? `${credential.label} (active)` : credential.label,
hint: credential.id,
})),
}),
)
})
@@ -1,16 +1,17 @@
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
import { intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { IntegrationInfo } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
Commands.commands.auth.commands.logout,
Effect.fn("cli.auth.logout")((input) =>
logout({
target: Option.getOrUndefined(input.target),
credential: Option.getOrUndefined(input.credential),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
@@ -19,65 +20,24 @@ export default Runtime.handler(
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
target?: string
credential?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Remove credential")
if (!input.credential)
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
intro("Log out of an account")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integrations = yield* loadIntegrations(client)
const integration = yield* chooseIntegration(integrations, input.target)
const credentials = integration.connections.filter((connection) => connection.type === "credential")
if (credentials.length === 0) {
const environment = integration.connections
.filter((connection) => connection.type === "env")
.map((connection) => connection.name)
if (environment.length) {
yield* Effect.fail(
new Error(
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
),
)
}
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
}
const credentialID = yield* chooseCredential(integration, "log out", input.credential)
const progress = spinner()
progress.start("Removing credential...")
yield* Effect.forEach(
credentials,
(connection) =>
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
outro("Done")
})
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
integrations: IntegrationInfo[],
target?: string,
) {
if (target) return yield* resolveIntegration(integrations, target)
const configured = integrations.filter((integration) =>
integration.connections.some((connection) => connection.type === "credential"),
)
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: configured.map((integration) => ({
value: integration.id,
label: integration.name,
hint: integration.connections
.filter((connection) => connection.type === "credential")
.map((connection) => connection.label)
.join(", "),
})),
}),
)
return yield* resolveIntegration(configured, id)
})
@@ -0,0 +1,43 @@
import { intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
Commands.commands.auth.commands.switch,
Effect.fn("cli.auth.switch")((input) =>
switchAccount({
target: Option.getOrUndefined(input.target),
credential: Option.getOrUndefined(input.credential),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
target?: string
credential?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
if (!input.credential)
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
intro("Switch account")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integrations = yield* loadIntegrations(client)
const integration = yield* chooseIntegration(integrations, input.target)
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
const progress = spinner()
progress.start("Switching account...")
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
)
outro("Done")
})
@@ -1,110 +0,0 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode/client/promise"
import { AppProcess } from "@opencode/util/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
import { errorMessage } from "../../../util/error"
const integrationID = "opencode"
const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.console.commands.login,
Effect.fn("cli.console.login")(function* (input) {
const timeline = yield* Effect.acquireRelease(
Effect.promise(() => createTimelineHost()),
(value) => request(() => value.close()).pipe(Effect.ignore),
)
const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe(
Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)),
Effect.exit,
)
if (Exit.isSuccess(exit)) return
const cancelled = timeline.signal.aborted
yield* request(() =>
timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))),
).pipe(Effect.ignore)
process.exitCode = cancelled ? 130 : 1
}),
)
const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) {
yield* request(() => timeline.intro("Log in"))
yield* request(() => timeline.pending("Connecting to OpenCode..."))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
const method = yield* required(
integration.methods.find((candidate) => candidate.type === "oauth"),
"OpenCode Console login is unavailable",
)
yield* request(() => timeline.pending("Starting authorization..."))
const started = yield* request((signal) =>
client.integration.oauth.connect(
{
integrationID,
methodID: method.id,
...(server ? { answer: { server } } : {}),
location,
},
{ signal },
),
)
const attempt = started.data
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.oauth.cancel(
{ integrationID, attemptID: attempt.attemptID, location },
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login"))
yield* request(() => timeline.item(`Go to: ${attempt.url}`))
yield* request(() => timeline.item(attempt.instructions))
yield* request(async () => {
const { default: open } = await import("open")
await open(attempt.url)
}).pipe(Effect.ignore)
yield* request(() => timeline.pending("Waiting for authorization..."))
const status = yield* waitForConsoleLogin(client, integrationID, attempt.attemptID)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
if (status.status === "expired") yield* Effect.fail(new Error("Device code expired"))
yield* request(() => timeline.success("Connected to OpenCode Console"))
yield* request(() => timeline.outro("Done"))
})
const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
) {
while (true) {
const response = yield* request((signal) =>
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
yield* Effect.sleep(500)
}
})
function request<A>(task: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({
try: task,
catch: (cause) => cause,
})
}
function required<A>(value: A | null | undefined, message: string) {
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
}
@@ -3,14 +3,14 @@ import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../util/error"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")((input) =>
Commands.commands.session.commands.export,
Effect.fn("cli.session.export")((input) =>
Effect.gen(function* () {
const requested = Option.getOrUndefined(input.session)
if (!requested && !process.stdin.isTTY) {
@@ -5,13 +5,13 @@ import { SessionTransfer } from "@opencode/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.import,
Effect.fn("cli.import")(function* (input) {
Commands.commands.session.commands.import,
Effect.fn("cli.session.import")(function* (input) {
const text = yield* Effect.tryPromise({
try: () =>
input.file.startsWith("http://") || input.file.startsWith("https://")
+8 -5
View File
@@ -15,6 +15,11 @@ import { Npm } from "@opencode/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
}
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
@@ -24,15 +29,13 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
switch: () => import("./commands/handlers/auth/switch"),
},
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
paths: () => import("./commands/handlers/debug/paths"),
},
console: {
login: () => import("./commands/handlers/console/login"),
},
mcp: {
list: () => import("./commands/handlers/mcp/list"),
add: () => import("./commands/handlers/mcp/add"),
@@ -48,14 +51,14 @@ const Handlers = Runtime.handlers(Commands, {
},
models: () => import("./commands/handlers/models"),
stats: () => import("./commands/handlers/stats"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
export: () => import("./commands/handlers/session/export"),
import: () => import("./commands/handlers/session/import"),
},
service: {
start: () => import("./commands/handlers/service/start"),
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createServer } from "node:net"
import path from "node:path"
test("the executable askpass branch returns only the response, without CLI output", async () => {
const requests: string[] = []
const server = createServer((socket) => {
socket.once("data", (data: Buffer) => {
requests.push(data.toString())
socket.end(JSON.stringify({ value: 'passphrase"with spaces' }))
})
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address()
if (!address || typeof address === "string") throw new Error("missing listener")
try {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "index.ts"), "Enter passphrase:"], {
env: {
...process.env,
OPENCODE_SSH_ASKPASS_PORT: String(address.port),
OPENCODE_SSH_ASKPASS_TOKEN: "fixture",
SSH_ASKPASS_PROMPT: "confirm",
},
stdout: "pipe",
stderr: "pipe",
})
expect(await new Response(child.stdout).text()).toBe('passphrase"with spaces\n')
expect(await child.exited).toBe(0)
expect(requests.map((request) => JSON.parse(request))).toEqual([
{ token: "fixture", text: "Enter passphrase:", confirm: true },
])
expect(await new Response(child.stderr).text()).toBe("")
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}, 30_000)
+40
View File
@@ -0,0 +1,40 @@
import { NodeSocket } from "@effect/platform-node"
import { Effect, Schema, Stdio, Stream } from "effect"
const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
// OpenSSH invokes the executable directly, including on Windows. Run outside
// normal CLI observability so neither prompts nor responses enter its logs.
export const askpass = Effect.gen(function* () {
const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
const stdio = yield* Stdio.Stdio
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
const write = yield* socket.writer
const response = { text: "" }
yield* Effect.all(
[
socket.runString((text) =>
Effect.sync(() => {
response.text += text
}),
),
write(
JSON.stringify({
token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
text: process.argv.slice(2).join(" "),
confirm: process.env.SSH_ASKPASS_PROMPT === "confirm",
}) + "\n",
),
],
{ concurrency: "unbounded", discard: true },
)
const result = yield* Schema.decodeUnknownEffect(Response)(response.text)
if (result.value === null) return 1
yield* Stream.make(result.value + "\n").pipe(Stream.run(stdio.stdout({ endOnDone: false })))
return 0
}).pipe(
Effect.scoped,
Effect.timeout("5 minutes"),
Effect.orElseSucceed(() => 1),
)
+14 -5
View File
@@ -71,13 +71,14 @@ test("export is raw by default and supports explicit sanitization", async () =>
})
try {
const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()])
const [stdout, , exitCode] = await run(["session", "export", info.id, "--server", server.url.toString()])
const exported = JSON.parse(stdout)
expect(exitCode).toBe(0)
expect(exported).toEqual(transfer)
const [sanitized, , sanitizedExitCode] = await run([
"session",
"export",
info.id,
"--sanitize",
@@ -110,7 +111,7 @@ test("export requires a session outside an interactive terminal", async () => {
})
try {
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
const [stdout, stderr, exitCode] = await run(["session", "export", "--server", server.url.toString()])
expect(exitCode).toBe(1)
expect(stdout).toBe("")
@@ -138,7 +139,7 @@ test("export reports a missing session without a stack trace", async () => {
})
try {
const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()])
const [stdout, stderr, exitCode] = await run(["session", "export", sessionID, "--server", server.url.toString()])
expect(exitCode).toBe(1)
expect(stdout).toBe("")
@@ -173,7 +174,15 @@ test("import validates a file and sends it to the resolved location", async () =
})
try {
const [stdout, , exitCode] = await run(["import", file, "--directory", root, "--server", server.url.toString()])
const [stdout, , exitCode] = await run([
"session",
"import",
file,
"--directory",
root,
"--server",
server.url.toString(),
])
expect(exitCode).toBe(0)
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
@@ -205,7 +214,7 @@ test("import reports an existing session without a stack trace", async () => {
})
try {
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
const [stdout, stderr, exitCode] = await run(["session", "import", file, "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
+13 -27
View File
@@ -518,7 +518,6 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly messages?: ReadonlyArray<SessionMessage.InfoEncoded> | undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
@@ -1030,19 +1029,6 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly to: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.message.content.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
}
}
| {
readonly id: Event.ID
readonly created: number
@@ -1062,6 +1048,19 @@ export type SessionLogOutput =
}
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.message.content.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
}
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
@@ -1082,18 +1081,6 @@ export type SessionMessageInput = { readonly sessionID: Session.ID; readonly mes
export type SessionMessageOutput = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: SessionMessageInput) => Effect.Effect<SessionMessageOutput, E>
export type SessionMessageUpdateInput = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<
SessionMessage.AssistantText | SessionMessage.AssistantReasoning | SessionMessage.AssistantTool
>
}
export type SessionMessageUpdateOutput = SessionMessage.Assistant
export type SessionMessageUpdateOperation<E = never> = (
input: SessionMessageUpdateInput,
) => Effect.Effect<SessionMessageUpdateOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1152,7 +1139,6 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly messageUpdate: SessionMessageUpdateOperation<E>
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -92,8 +92,6 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -691,17 +689,6 @@ const EndpointSessionMessage = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionMessageUpdate = (raw: RawClient["server.session"]) => (input: SessionMessageUpdateInput) =>
preserveEffect<SessionMessageUpdateOutput>()(
raw["session.messageUpdate"]({
params: { sessionID: input["sessionID"], messageID: input["messageID"] },
payload: { content: input["content"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -762,7 +749,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: EndpointSessionMessage(raw),
messageUpdate: EndpointSessionMessageUpdate(raw),
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -86,8 +86,6 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -986,18 +984,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
messageUpdate: (input: SessionMessageUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionMessageUpdateOutput }>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
body: { content: input["content"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
+87 -324
View File
@@ -159,76 +159,6 @@ export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionMessageAgentSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type SessionMessageSynthetic1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
description?: string
type: "synthetic"
}
export type SessionMessageSystem1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "skill"
skill: string
name: string
text: string
}
export type SessionMessageShell1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; completed?: number }
type: "shell"
shellID: string
command: string
status: "running" | "exited" | "timeout" | "killed"
exit?: number
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageCompactionRunning1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "running"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -244,6 +174,16 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -514,17 +454,6 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMessageLocationSwitched1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type V2EventRpc = {
id: string
created: number
@@ -560,15 +489,6 @@ export type SessionMessageModelSelected = {
previous?: ModelRef
}
export type SessionMessageModelSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type PromptFileAttachment = {
data: PromptBase64
mime: string
@@ -605,16 +525,6 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionMessageCompactionFailed1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "failed"
reason: "auto" | "manual"
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
@@ -1290,13 +1200,37 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type SessionStepEnded = {
@@ -1399,41 +1333,17 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
@@ -1793,17 +1703,6 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageUser1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
type: "user"
}
export type SessionInboxUserPayload1 = {
text: string
files?: Array<PromptFileAttachment>
@@ -1841,20 +1740,6 @@ export type SessionMessageCompactionCompleted = {
providerContext?: SessionProviderContext
}
export type SessionMessageCompactionCompleted1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
@@ -1873,19 +1758,20 @@ export type SessionCompactionEnded = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type SessionToolSuccess = {
@@ -1925,6 +1811,21 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2203,11 +2104,6 @@ export type SessionMessageCompaction =
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompaction1 =
| SessionMessageCompactionRunning1
| SessionMessageCompactionCompleted1
| SessionMessageCompactionFailed1
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -2257,24 +2153,6 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistant1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
content: Array<SessionMessageAssistantText1 | SessionMessageAssistantReasoning1 | SessionMessageAssistantTool1>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
@@ -2300,18 +2178,6 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageInfoEncoded =
| SessionMessageAgentSelected1
| SessionMessageModelSelected1
| SessionMessageLocationSwitched1
| SessionMessageUser1
| SessionMessageSynthetic1
| SessionMessageSystem1
| SessionMessageSkill1
| SessionMessageShell1
| SessionMessageAssistant1
| SessionMessageCompaction1
export type SessionMessageContentUpdated = {
id: string
created: number
@@ -2344,31 +2210,6 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
messages?: Array<SessionMessageInfoEncoded>
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2411,8 +2252,16 @@ export type SessionEventDurable =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| SessionUsageRecorded
| SessionMessageContentUpdated
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
@@ -2468,7 +2317,6 @@ export type V2Event =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| FilesystemChanged
| ReferenceUpdated
| PermissionAsked
@@ -4421,91 +4269,6 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionMessageUpdateInput = {
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
readonly content: {
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
}["content"]
}
export type SessionMessageUpdateOutput = { data: SessionMessageAssistant }["data"]
export type SessionEnvironmentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
-10
View File
@@ -827,16 +827,6 @@ export function createData(config: CreateDataInput) {
match.time.completed = event.created
})
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
refresh(() => result.session.message.sync(event.data.sessionID))
return
}
case "session.step.started":
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.assistantMessageID)
-71
View File
@@ -744,77 +744,6 @@ test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-
},
)
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
let requests = 0
const content = [
{ type: "text" as const, text: "replacement" },
{ type: "reasoning" as const, text: "reasoning", time: { created: 3 } },
]
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
const current = ++requests
if (current === 2) await release.promise
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: current === 3 ? content : [{ type: "text", text: "original" }],
time: { created: 1, completed: 2 },
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
setup.data.session.message.invalidate("ses_refresh")
const stale = setup.data.session.message.sync("ses_refresh")
await wait(() => requests === 2)
const updated: OpenCodeEvent = {
id: "evt_message_updated",
created: 3,
type: "session.message.content.updated",
durable: { aggregateID: "ses_refresh", seq: 3, version: 1 },
data: {
sessionID: "ses_refresh",
messageID: "msg_assistant",
content,
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
release.resolve()
await stale
await wait(() => requests === 3)
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
} finally {
setup.dispose()
}
})
test.each([
"session.execution.succeeded",
"session.execution.failed",
-1
View File
@@ -513,7 +513,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => sessions.get(input.sessionID),
fork: sessions.fork,
switchAgent: sessions.switchAgent,
switchModel: sessions.switchModel,
prompt: sessions.prompt,
+2 -40
View File
@@ -4,7 +4,7 @@ export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { ListAnchor } from "@opencode/schema/session"
import { and, asc, desc, eq, lt, lte, sql } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode/schema/model"
import { Location } from "./location.js"
@@ -23,7 +23,6 @@ import { Slug } from "./util/slug.js"
import path from "path"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { decodeMessageRow } from "./session/history.js"
import { SessionExecution } from "./session/execution.js"
import {
AttachmentError,
@@ -32,10 +31,7 @@ import {
ForkEmptyError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
@@ -94,7 +90,6 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
filter?: (messages: readonly SessionMessage.Info[]) => readonly SessionMessage.Info[]
}
export {
@@ -103,10 +98,7 @@ export {
CompactionConflictError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
@@ -138,9 +130,6 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined>
readonly updateMessage: (
input: Parameters<Session.Handle["updateMessage"]>[0] & { readonly sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["updateMessage"]>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
@@ -295,7 +284,7 @@ const layer = Layer.effect(
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(
and(
@@ -313,31 +302,6 @@ const layer = Layer.effect(
messageID: input.boundary.messageID,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const messages = input.filter
? input.filter(
yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, parent.id),
input.boundary.type === "before"
? lt(SessionMessageTable.seq, boundary.seq)
: lte(SessionMessageTable.seq, boundary.seq),
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(
Effect.orDie,
Effect.flatMap((rows) => Effect.forEach(rows, decodeMessageRow)),
Effect.orDie,
),
)
: undefined
const sessionID = SessionSchema.ID.create()
const inherited = yield* db
.transaction(() =>
@@ -354,7 +318,6 @@ const layer = Layer.effect(
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
messages: messages === undefined ? undefined : Schema.encodeSync(Schema.Array(SessionMessage.Info))(messages),
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
@@ -385,7 +348,6 @@ const layer = Layer.effect(
return yield* store.messages(input)
}),
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
context: Effect.fn("Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
-24
View File
@@ -16,30 +16,6 @@ export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundErro
messageID: SessionMessage.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class ForkEmptyError extends Schema.TaggedError<ForkEmptyError>()("Session.ForkEmptyError", {
sessionID: SessionSchema.ID,
}) {
-26
View File
@@ -179,32 +179,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
if (event.data.messages !== undefined) {
if (event.data.messages.length > 0) {
yield* db
.insert(SessionMessageTable)
.values(
event.data.messages.map((message, index) => {
const { id: _, type, ...data } = message
return {
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${index + 1}`),
session_id: event.data.sessionID,
type,
seq: index + 1,
time_created: data.time.created,
data,
}
}),
)
.run()
.pipe(Effect.orDie)
yield* Bus.reserveSequence(db, event.data.sessionID, event.data.messages.length)
}
if (event.data.instructions)
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
return
}
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
+1 -31
View File
@@ -1,6 +1,6 @@
export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Schema, Scope } from "effect"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import { Event } from "@opencode/schema/event"
@@ -14,10 +14,7 @@ import {
BusyError,
CompactionConflictError,
InboxConflictError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SyntheticConflictError,
@@ -61,26 +58,6 @@ export const make = Effect.fn("Session.make")(function* () {
const stored = yield* store.message(messageID)
return stored?.sessionID === sessionID ? stored.message : undefined
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (
sessionID: SessionSchema.ID,
input: { readonly messageID: SessionMessage.ID; readonly content: readonly SessionMessage.AssistantContent[] },
) {
const ref = { sessionID, messageID: input.messageID }
yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
const current = yield* message(sessionID, input.messageID)
if (!current) return yield* new MessageNotFoundError(ref)
if (current.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!current.time.completed) return yield* new MessageIncompleteError(ref)
if (input.content.some(isUnfinishedTool)) return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* message(sessionID, input.messageID)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
})
const view = Effect.fn("Session.view")(function* (sessionID: SessionSchema.ID, input: { idle: number }) {
const session = yield* get(sessionID)
if (
@@ -355,7 +332,6 @@ export const make = Effect.fn("Session.make")(function* () {
const operations = {
get,
message,
updateMessage,
view,
rename,
switchAgent,
@@ -378,7 +354,6 @@ export const make = Effect.fn("Session.make")(function* () {
const forSession = (sessionID: SessionSchema.ID) => {
const get = operations.get.bind(undefined, sessionID)
const message = operations.message.bind(undefined, sessionID)
const updateMessage = operations.updateMessage.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
@@ -404,7 +379,6 @@ export const make = Effect.fn("Session.make")(function* () {
id: sessionID,
get,
message,
updateMessage,
view,
rename,
switchAgent,
@@ -429,8 +403,4 @@ export const make = Effect.fn("Session.make")(function* () {
export type Handle = ReturnType<Effect.Success<ReturnType<typeof make>>["forSession"]>
function isUnfinishedTool(content: SessionMessage.AssistantContent) {
return content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running")
}
// Mirrors the shell tool's in-memory preview safety limit.
+1 -1
View File
@@ -22,7 +22,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response."
"You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes."
const OS =
process.platform === "darwin"
? "macOS"
+22
View File
@@ -449,6 +449,28 @@ it.live("retains Promise plugin groups for later registrations and ignores a dis
}),
)
it.effect("normalizes Promise plugin API inputs through JSON", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const created: boolean[] = []
yield* plugins.activate([
{
...fromPromise({
id: "promise-input",
async setup(ctx) {
await ctx.session.create({ title: "Promise session", agent: undefined })
created.push(true)
},
}),
revision: "1",
},
])
yield* plugins.awaitActivation
expect(created).toEqual([true])
}),
)
it.effect("reloading a plugin replaces its command implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
-1
View File
@@ -162,7 +162,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
fork: overrides.session?.fork ?? (() => Effect.die("unused session.fork")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
switchAgent: overrides.session?.switchAgent ?? (() => Effect.die("unused session.switchAgent")),
switchModel: overrides.session?.switchModel ?? (() => Effect.die("unused session.switchModel")),
-95
View File
@@ -40,9 +40,6 @@ import { offlineModels } from "./fixture/models"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
import { Plugin } from "@opencode/plugin"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { host } from "./plugin/host"
const it = testEffect(
AppNodeBuilder.build(
@@ -564,98 +561,6 @@ describe("Session.create", () => {
}),
)
it.effect("freezes filtered fork history and replays it without invoking the callback", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Original note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const last = yield* session.prompt({ sessionID: parent.id, text: "Excluded by boundary", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const calls: number[] = []
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: last.id },
filter: (messages) => {
calls.push(messages.length)
return messages.flatMap((message) =>
message.type === "synthetic" ? [{ ...message, text: "Filtered note" }] : [],
)
},
})
const original = yield* session.context(forked.id)
expect(calls).toEqual([2])
expect(original).toMatchObject([{ type: "synthetic", text: "Filtered note" }])
const event = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))[0]
if (event.type !== "session.forked") return yield* Effect.die(new Error("Fork event not found"))
expect(typeof event.data.messages?.[0].time.created).toBe("number")
expect((yield* session.context(parent.id))[1]).toMatchObject({ text: "Original note" })
const recorded = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, forked.id)).get()
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
yield* bus.replay({
id: recorded.id,
created: recorded.created,
aggregateID: recorded.aggregate_id,
seq: recorded.seq,
type: recorded.type,
data: recorded.data,
})
expect(yield* session.context(forked.id)).toEqual(original)
expect(calls).toEqual([2])
yield* session.prompt({ sessionID: forked.id, text: "Continue", resume: false })
yield* SessionInbox.promote(db, bus, forked.id, "steer")
expect(yield* session.context(forked.id)).toMatchObject([
{ type: "synthetic", text: "Filtered note" },
{ type: "user", text: "Continue" },
])
const empty = yield* session.fork({
sessionID: parent.id,
boundary: { type: "through" },
filter: () => [],
})
expect(yield* session.context(empty.id)).toEqual([])
}),
)
it.effect("exposes filtered forks to Promise plugins with decoded callback messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "Keep", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Drop", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "filtered-fork",
async setup(ctx) {
const fork = await ctx.session.fork({
sessionID: parent.id,
boundary: { type: "through" },
filter: (messages) => {
expect(DateTime.isDateTime(messages[0].time.created)).toBe(true)
return messages.filter((message) => message.type === "user")
},
})
expect(typeof fork.time.created).toBe("number")
expect(await ctx.session.context({ sessionID: fork.id })).toMatchObject([{ type: "user", text: "Keep" }])
},
}),
)
yield* plugin.effect(
host({ session: { fork: session.fork, context: (input) => session.context(input.sessionID) } }),
)
}),
)
it.effect("keeps a fork untitled when its parent is untitled", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1,280 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { EventTable } from "@opencode/core/event/sql"
import { Location } from "@opencode/core/location"
import { Model } from "@opencode/core/model"
import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { SessionStore } from "@opencode/core/session/store"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
const active = new Set<Session.ID>()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
Bus.node.replace(Bus.configured({ persist: true })),
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => active),
isActive: (sessionID) => Effect.sync(() => active.has(sessionID)),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
}),
),
),
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
const start = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: messageID,
agent: Agent.defaultID,
model,
})
const complete = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionMessage.ID) =>
bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: messageID,
finish: "stop",
cost: Money.USD.make(0),
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
describe("Session.updateMessage", () => {
it.effect("replaces assistant content through a durable projected event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.AssistantText.make({ type: "text", text: "replacement" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "updated reasoning",
time: { created: created.time.created },
}),
]
const updated = yield* session.updateMessage({ sessionID: created.id, messageID, content })
expect(updated.content).toEqual(content)
expect(yield* session.message({ sessionID: created.id, messageID })).toMatchObject({ content })
expect((yield* session.messages({ sessionID: created.id }))[0]).toMatchObject({ id: messageID, content })
const events = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
expect(events.at(-2)).toMatchObject({
type: "session.message.content.updated",
data: {
sessionID: created.id,
messageID,
content: [
{ type: "text", text: "replacement" },
{ type: "reasoning", text: "updated reasoning", time: { created: expect.any(Number) } },
],
},
})
expect(
yield* db
.select()
.from(EventTable)
.where(eq(EventTable.type, Bus.versionedType(SessionEvent.MessageContentUpdated.type, 1)))
.get(),
).toMatchObject({ aggregate_id: created.id, data: { messageID } })
expect((yield* session.updateMessage({ sessionID: created.id, messageID, content: [] })).content).toEqual([])
}),
)
it.effect("replays updated assistant content into a fresh projection", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "replayed reasoning",
time: { created: created.time.created },
}),
]
yield* session.updateMessage({ sessionID: created.id, messageID, content })
const serialized = (yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* tmpdirScoped()
const target = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })),
Bus.node.replace(Bus.configured({ persist: true })),
],
)
yield* Effect.gen(function* () {
const database = (yield* Database.Service).db
const replay = yield* Bus.Service
const store = yield* SessionStore.Service
yield* database
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => replay.replay(event), { discard: true })
expect((yield* store.message(messageID))?.message).toMatchObject({ content })
}).pipe(Effect.provide(Layer.fresh(target)))
}),
)
it.effect("rejects missing and cross-session messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const other = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
expect(yield* Effect.flip(session.updateMessage({ sessionID: other.id, messageID, content: [] }))).toEqual(
new Session.MessageNotFoundError({ sessionID: other.id, messageID }),
)
const missing = Session.ID.create()
expect(yield* Effect.flip(session.updateMessage({ sessionID: missing, messageID, content: [] }))).toEqual(
new Session.NotFoundError({ sessionID: missing }),
)
}),
)
it.effect("rejects non-assistant messages, incomplete assistants, and unfinished tools", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const synthetic = yield* bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: "synthetic" })
const syntheticID = SessionMessage.ID.fromEvent(synthetic.id)
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID: syntheticID, content: [] })),
).toEqual(new Session.MessageNotAssistantError({ sessionID: created.id, messageID: syntheticID }))
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
expect(yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))).toEqual(
new Session.MessageIncompleteError({ sessionID: created.id, messageID }),
)
yield* complete(bus, created.id, messageID)
yield* Effect.forEach(
[
SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
SessionMessage.ToolStateRunning.make({ status: "running", input: {}, metadata: {} }),
],
Effect.fnUntraced(function* (state) {
const unfinished = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_unfinished",
name: "read",
state,
time: { created: created.time.created },
})
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [unfinished] })),
).toEqual(new Session.MessageToolIncompleteError({ sessionID: created.id, messageID }))
}),
)
}),
)
it.effect("accepts completed and failed tool content", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "result" }],
}),
SessionMessage.ToolStateError.make({ status: "error", input: {}, error: { type: "tool", message: "failed" } }),
].map((state) =>
SessionMessage.AssistantTool.make({
type: "tool",
id: `call_${state.status}`,
name: "read",
state,
time: { created: created.time.created },
}),
)
expect((yield* session.updateMessage({ sessionID: created.id, messageID, content })).content).toEqual(content)
}),
)
it.effect("rejects a completed assistant while its session is active", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
active.add(created.id)
const failure = yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [] }))
active.delete(created.id)
expect(failure).toEqual(new Session.BusyError({ sessionID: created.id }))
}),
)
})
+3 -5
View File
@@ -166,7 +166,7 @@ const setup = Effect.fnUntraced(function* (options?: {
})
describe("Session-owned handles", () => {
it.live("owns state changes and message editing without caller services or Location acquisition", () =>
it.live("owns state changes and message reads without caller services or Location acquisition", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
@@ -191,7 +191,7 @@ describe("Session-owned handles", () => {
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
const { rename, switchAgent, switchModel, view, message, updateMessage } = handle
const { rename, switchAgent, switchModel, view, message } = handle
yield* Effect.gen(function* () {
yield* rename({ title: "Renamed" })
@@ -200,9 +200,7 @@ describe("Session-owned handles", () => {
yield* switchModel({ model })
yield* view({ idle: 0 })
yield* view({ idle: 0 })
const content = [SessionMessage.AssistantText.make({ type: "text", text: "Edited" })]
expect((yield* updateMessage({ messageID, content })).content).toEqual(content)
expect(yield* message(messageID)).toMatchObject({ type: "assistant", content })
expect(yield* message(messageID)).toMatchObject({ type: "assistant", content: [] })
}).pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
const session = yield* handle.get()
+2 -2
View File
@@ -1464,7 +1464,7 @@ describe("ShellTool", () => {
},
{
type: "text",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
@@ -1730,7 +1730,7 @@ describe("ShellTool", () => {
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
@@ -61,6 +61,8 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
"output" in result ? result.output.filter((item) => item.type === "chunk") : [],
)
expect(chunks.length).toBeGreaterThan(0)
// Resource resolution must not depend on which lazy entry owns DesktopPaths.
expect(chunks.every((chunk) => !chunk.fileName.includes("/"))).toBe(true)
const imports = chunks.flatMap((chunk) => [...chunk.imports, ...chunk.dynamicImports])
const modules = chunks.flatMap((chunk) => Object.keys(chunk.modules))
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
+3
View File
@@ -45,6 +45,9 @@ export default defineConfig(({ command }) => ({
// corrupt bundled TypeScript, while an output banner places the shim safely.
output: {
format: "es",
// DesktopPaths resolves resources from the main output directory,
// including when the lazy desktop entry shares it with other chunks.
chunkFileNames: "[name]-[hash].js",
banner: `
// -- CommonJS Shims --
import __cjs_mod__ from 'node:module';
+32
View File
@@ -0,0 +1,32 @@
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
})
runIpc().pipe(
Effect.provide(Ipc.layer),
Effect.provide(BackgroundService.layer),
Effect.provide(DesktopCli.layer),
Effect.provide(UpdaterLive.layer),
Effect.provide(DesktopInitialization.layer),
Effect.provide(ApplicationLifecycle.layer),
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
Effect.scoped,
NodeRuntime.runMain,
)
+2 -33
View File
@@ -1,34 +1,3 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
export {}
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
})
runIpc().pipe(
Effect.provide(Ipc.layer),
Effect.provide(BackgroundService.layer),
Effect.provide(DesktopCli.layer),
Effect.provide(UpdaterLive.layer),
Effect.provide(DesktopInitialization.layer),
Effect.provide(ApplicationLifecycle.layer),
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
Effect.scoped,
NodeRuntime.runMain,
)
await import("./desktop")
@@ -0,0 +1,25 @@
import { Effect } from "effect"
import { SshRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Ssh } from "../ssh/service"
import { sender } from "./context"
export const sshHandlers = SshRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const ssh = yield* Ssh.Service
return SshRpcs.of({
SshGetState: (_args, context) => ssh.state(sender(handoff, context).id),
SshSubscribe: (_args, context) => ssh.subscribeWindow(sender(handoff, context)),
SshUnsubscribe: (_args, context) => ssh.unsubscribeWindow(sender(handoff, context).id),
SshHosts: () => ssh.hosts(),
SshStart: (input, context) => ssh.start(input, input.background ? undefined : sender(handoff, context).id),
SshResolve: ({ id }) => ssh.resolve(id),
SshRespond: ({ id, prompt, value }, context) => ssh.respond(id, prompt, value, sender(handoff, context).id),
SshDisconnect: ({ id }) => ssh.disconnect(id),
SshCancel: ({ id }, context) => ssh.cancel(id, sender(handoff, context).id),
SshForget: ({ id }) => ssh.forget(id).pipe(Effect.orDie),
SshOpenConfig: () => ssh.openConfig().pipe(Effect.orDie),
})
}),
)
+4 -1
View File
@@ -14,6 +14,8 @@ import { storageHandlers } from "./ipc-handlers/storage"
import { updaterHandlers } from "./ipc-handlers/updater"
import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { sshHandlers } from "./ipc-handlers/ssh"
import { Ssh } from "./ssh/service"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
@@ -23,7 +25,7 @@ import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -32,6 +34,7 @@ const handlers = Layer.mergeAll(
menuHandlers,
updaterHandlers,
wslHandlers,
sshHandlers,
eventHandlers,
)
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(

Some files were not shown because too many files have changed in this diff Show More