mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
16
Commits
plugin-fork
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd9b18f3d | ||
|
|
b43e1c682b | ||
|
|
d11f5916ee | ||
|
|
f1ce69d2ce | ||
|
|
d39290fbb9 | ||
|
|
6ee2ed7510 | ||
|
|
594635b5ae | ||
|
|
18b05e86fb | ||
|
|
cc501650c6 | ||
|
|
37b6fbc5bf | ||
|
|
d24f8b0810 | ||
|
|
79a6a90862 | ||
|
|
8c5eca5bb2 | ||
|
|
f3128fa241 | ||
|
|
883d16d2ad | ||
|
|
5c30292daa |
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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())}
|
||||
|
||||
@@ -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 aren’t 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 host’s 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" /> }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
+6
-6
@@ -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
-5
@@ -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://")
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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("")
|
||||
|
||||
@@ -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>(
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}) {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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 }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user