Compare commits

..
96 changed files with 2302 additions and 2823 deletions
+135
View File
@@ -0,0 +1,135 @@
# Grouped timeline implementation log
## Delivery agreement
Work sequentially, one task at a time. Prefer small independently landable PRs
targeting `v2`. Combine tasks only when necessary for a working integration.
Do not silently change production behavior, even if a difference seems better.
Diagnostic parity fixtures/tests and screenshots are local evidence, not intended
shipping changes unless explicitly agreed.
## Ordered tasks and PR boundaries
| Task | Scope / completion gate | Status | Proposed PR |
| --- | --- | --- | --- |
| 1. Baseline | Record clean revision, run existing checks, inventory behavior and capture real development TUI | Complete; evidence below | Documentation-only baseline PR (`grouped-timeline`) |
| 2. Shared renderers | Extract renderers and shared context from session/index.tsx; preserve layout, subscriptions, defaults and behavior | Not started | `refactor(tui): extract session renderers` |
| 3. Generic engine | Recursive grouping paths, seam merge/split, stable identities, idempotent ingestion, cached leaf counts | Not started | Pure engine + focused tests, independently landable if useful |
| 4. Production integration | Connect store updates to engine with existing reasoning/exploration rules; establish parity before removing old logic | Not started | Engine integration; combine with task 3 if that avoids an unused module |
| 5. Tree rendering/navigation | Recursive rendering, registered message anchors, measured OpenTUI offsets, reveal ancestors | Not started | Combine necessary tree wiring with task 4; remaining navigation work separately |
| 6. History/mounting | Complete boundary groups by fetching backward; stable viewport; leaf-based soft budget | Not started | Group-aware history PR |
| 7. Experiment | Low/Medium/High configuration, activity/instruction groups and agreed summaries/defaults | Not started | Experimental grouping PR |
| 8. End-to-end verification | Full lifecycle, pagination, navigation, replay, long sessions, narrow/wide Drive captures | Not started | Verification accompanies every PR; final integration evidence here |
Task 1 lands as a documentation-only PR from `grouped-timeline` into `v2`.
Task 2 is the next implementation task and will follow in a separate PR.
## Agreed architecture
- One production grouping engine; experiment selects rules/presentation, not a separate session route.
- Tree is the source of truth and renders recursively; no flat rendering projection.
- Existing message types and PartRef conventions remain authoritative.
- Two-level grouping is supported from the start: activity -> exploration/reasoning/instructions -> leaves.
- Shared production thinking/exploration status presentation remains unchanged for default rules.
- Transcript owns message anchors registered by nested renderers. Read actual OpenTUI geometry after layout.
- Leaf counts, not group counts, drive a soft mounting budget. Group wrappers count as zero.
- Summary membership is independent of the mounted slice and disclosure state.
- Fetch backward until the oldest group has a known boundary or history is exhausted.
Stage incomplete boundary content; reveal complete groups while preserving the reader's anchor.
No new loading indicator. A very long group may require several pages.
- Register the feature in the existing permanent Experiments framework; preserve that framework.
## Task 1: production baseline
### Revision and environment
- Worktree: `/root/projects/opencode-grouped-timeline`
- Branch: `grouped-timeline`
- Clean starting revision: `8f4d7066473ea07d26c5dfc35e46cd9a94e3e292`
- Subject: `feat(cli): add command docs and simplify session list`
- Created from fetched `origin/v2`; previous verbosity prototype edits remain in the other worktree.
- No production source changes made for this task. Git retains the exact baseline source and existing test fixtures.
### Baseline behavior inventory
Source: `packages/tui/src/routes/session/rows.ts` and `index.tsx` at the revision above.
- Flat row union: messages, compaction-queued, parts, reasoning groups, exploration groups, assistant footers, usage.
- Exploration membership: case-insensitive read/glob/grep; webfetch/websearch remain standalone.
- Adjacent reasoning and exploration can span assistant-message boundaries.
- Empty text/reasoning is skipped during history reduction, but still consumes its per-type ordinal.
- Tool references use call IDs; text/reasoning references use per-type ordinals.
- Visible delimiters finish preceding groups. Terminal/retry assistant footers also finish groups.
- Synthetic messages without a nonblank description are skipped.
- Running compaction and queued input ordering are explicitly handled, not ordinary assistant parts.
- Exploration keeps refs, pending refs and completion state; reasoning keeps refs and completion state.
- Current rendering uses `Exploring — ...` / `Explored — ...`; do not substitute prototype colons during extraction.
- History loading compensates for scroll-height changes after layout; navigation uses message boundaries.
- Initial mounting uses 40 newest rows, with 60-row backfill chunks. Leaf budgeting is future work, not baseline behavior.
### Automated checks executed
Both commands run from `packages/tui`:
```sh
bun typecheck
bun run test
```
- Typecheck: passed.
- Suite: **1352 passed, 4 skipped, 0 failed**, 2 snapshots,
107855 assertions across 143 files, 107.88 seconds.
- Existing suite emitted resize-listener warnings and teardown/refresh diagnostics
(including a session-tab lock ENOENT and an UnexpectedStatus). These occurred
before implementation; the suite exited successfully.
- Full test output retained locally at
`/root/.local/share/opencode/shell/012780c4098d08caa4ea8c479ed0a4690489f38d/sh_08d3aa8b20014fbv25Iyo7BkR8.out`.
Relevant existing deterministic coverage in `packages/tui/test/cli/tui`:
| File | Baseline coverage |
| --- | --- |
| session-rows.test.ts | Group boundaries, cross-message grouping, empty parts, ordinals, synthetic messages, retry footers, compaction ordering |
| data.test.tsx | Live classification, queued delivery, failures, retry lifecycle, reconnect, revert, permissions/forms, optimistic admission |
| thinking.test.ts | Streamed title extraction and Markdown body preservation |
| session-history.test.ts | Prepend anchoring, failed fetch, session switch during fetch, navigation after layout |
| message-navigation.test.ts | User-only/all-message navigation, slack, bounds, logical anchors during layout |
| inline-tool-wrap-snapshot.test.tsx | Existing tool wrapping snapshots |
These tests establish the current reference, not proof that the future refactor is equivalent.
Each subsequent PR must compare affected outputs against this revision, and add diagnostic
coverage where existing fixtures do not exercise the change.
### Drive evidence
Local diagnostic script: `/tmp/opencode/grouping-baseline.ts`.
It uses a fixed project (`src/example.ts`), simulated reasoning, a real read tool,
and a delayed simulated final response to expose exploration's busy state.
```sh
opencode-drive check /tmp/opencode/grouping-baseline.ts
opencode-drive start --name grouping-baseline-tools \
--dev /root/projects/opencode-grouped-timeline \
--script /tmp/opencode/grouping-baseline.ts
```
Both commands passed. Drive shut down its isolated instance on completion.
PNG captures were opened and inspected:
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-exploring.png`: busy exploration, 112x34.
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-wide.png`: completed thought/read/text, 112x34.
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-narrow.png`: same completed state, 80x24.
The fixture's content is deterministic; real durations and spinner frames are not.
These PNGs are visual references, not byte-identical golden assertions. Future
exact layout comparisons need fixed message timestamps and controlled animation.
Long-history behavior is covered by the passing existing suite; expanded groups
and long-history Drive captures must be added before shipping changes to those paths.
## Next PR acceptance: shared-renderer extraction
1. Move code into a few coherent modules; retain JSX structure and behavior.
2. Preserve context/provider ownership and reactive reads; avoid new subscriptions.
3. Compare extracted component bodies to the baseline and run affected render tests.
4. Repeat typecheck and TUI suite; replay Drive fixture and compare layouts.
5. Record diff scope, results, evidence and PR URL here. Keep diagnostic-only files out of the PR.
@@ -59,9 +59,8 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -93,7 +92,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -114,10 +113,6 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -142,19 +137,12 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -164,7 +152,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -179,18 +167,9 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,12 +104,18 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,21 +2,20 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
@@ -1,452 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
const directory = "/workspace/summary-project"
const workspace = "/workspace/existing-worktree"
const createdWorkspace = "/workspace/created-worktree"
const draftID = "draft_summary"
const secondDraftID = "draft_summary_other"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const draftPath = `/new-session?draftId=${draftID}`
for (const rtl of [false, true]) {
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
page,
}, testInfo) => {
const mock = await openDraft(page)
if (rtl) {
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
}
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const view = await page.locator('[data-component="new-session"]').boundingBox()
if (!button || !view) return false
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
})
.toBe(true)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const view = await page.locator('[data-component="new-session"]').boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!view || !project || !server) return
return {
top: project.y - view.y - 48,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 6, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
})
for (const [name, item] of [
["MCP", "summary-mcp"],
["Plugins", "project-plugin"],
["Skills", "summary-skill"],
["LSP", "typescript"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
}
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
await expect
.poll(async () => {
const menu = await worktreeMenu.boundingBox()
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
if (!menu || !panel) return false
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
})
.toBe(true)
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
await page.keyboard.press("Enter")
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await mcp.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
expect(mock.status.get(directory)).toBe("connected")
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
).toBeVisible()
})
}
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
await expect(menu).toHaveCSS("opacity", "1")
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await expect
.poll(() =>
page.evaluate(
() =>
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
)?.mcp?.states,
),
)
.toEqual({ "summary-mcp": false })
await page.goto(`/new-session?draftId=${secondDraftID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).toBeChecked()
await page.goto(draftPath)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "worktree", directory },
{ type: "mcp", directory: createdWorkspace, enabled: false },
{ type: "session", directory: createdWorkspace },
{ type: "prompt", directory: createdWorkspace },
])
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
expect(mock.status.get(directory)).toBe("connected")
})
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
try {
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL(draftPath)
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
expect(mock.prompts).toEqual([])
} finally {
release.resolve()
}
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "mcp", directory, enabled: false },
{ type: "session", directory },
{ type: "prompt", directory },
])
})
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
mock.state.holdDirectory = directory
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
try {
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeEnabled()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
expect(mock.status.get(directory)).toBe("connected")
} finally {
release.resolve()
}
})
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
const mock = await openDraft(page, "create")
mock.state.fail = true
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
expect(mock.prompts).toEqual([])
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
mock.state.fail = false
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(mock.status.get(createdWorkspace)).toBe("disabled")
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
})
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
const mock = await openDraft(page, "create")
mock.status.set(createdWorkspace, "needs_auth")
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
return route.fulfill({
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: createdWorkspace },
data: {
id: "summary-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
const popup = page.waitForEvent("popup")
await page.locator('[data-action="composer-submit"]').click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
expect(attempts).toEqual([createdWorkspace])
expect(mock.prompts).toEqual([])
mock.status.set(createdWorkspace, "connected")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main") {
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const sessions: ReturnType<typeof currentSession>[] = []
const status = new Map<string, string>([
[directory, "connected"],
[workspace, "disabled"],
[createdWorkspace, "connected"],
])
const calls: { type: string; directory: string; enabled?: boolean }[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
project,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "summary-model" },
},
pageMessages: () => ({ items: [] }),
onPrompt(input) {
const session = sessions.find((session) => session.id === input.sessionID)
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
calls.push({ type: "prompt", directory: session.location.directory })
prompts.push(input)
},
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
if (route.request().method() === "POST") {
const enabled = url.pathname.endsWith("/connect")
calls.push({ type: "mcp", directory: target, enabled })
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
status.set(target, enabled ? "connected" : "disabled")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "summary-mcp",
integrationID: "summary-oauth",
status: { status: status.get(target) ?? "connected" },
},
],
},
})
})
await page.route(
(url) => url.pathname === "/api/location",
(route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: project.id, directory, canonical: directory },
},
}),
)
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
const id = target === directory ? "project-plugin" : "workspace-plugin"
return route.fulfill({
json: {
location: { directory: target },
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "summary-skill",
name: "summary-skill",
location: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
}),
)
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
calls.push({ type: "worktree", directory })
project.sandboxes.push(createdWorkspace)
return route.fulfill({ json: { directory: createdWorkspace } })
},
)
await page.route(
(url) => url.pathname === "/api/session",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
calls.push({ type: "session", directory: body.location.directory })
const session = currentSession(
{ ...body, projectID: project.id, title: "Created summary session" },
body.location.directory,
)
sessions.push(session)
return route.fulfill({ json: { data: session } })
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
)
await page.goto(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
await expect(
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
).toBeVisible()
return { calls, prompts, status, state }
}
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toHaveCount(0)
await expect(status).toBeVisible()
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
@@ -122,11 +122,12 @@ for (const direction of ["ltr", "rtl"] as const) {
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await details.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
await expect(mcp).toBeVisible()
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
@@ -1,56 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const theme of ["light", "dark"] as const) {
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockStressTimeline(page)
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
}, theme)
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
new URL(route.request().url()).pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
status: { status: "connected" },
})),
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
const summary = page.locator('[data-component="session-summary-panel"]')
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch")).toHaveCount(30)
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const bounds = await menu.boundingBox()
return (
!!bounds &&
bounds.x >= 15 &&
bounds.y >= 15 &&
bounds.x + bounds.width <= 785 &&
bounds.y + bounds.height <= 585
)
})
.toBe(true)
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
})
}
@@ -1,168 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
if (route.request().method() === "POST") {
expect(directory).toBe(fixture.directory)
writes.push(url.pathname)
state.enabled = url.pathname.endsWith("/connect")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
{ name: "waiting", status: { status: "pending" } },
],
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
"Sign in required",
)
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
await mockStressTimeline(page)
const state = { status: "disabled" }
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
state.status = "needs_auth"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
},
})
})
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(route.request().url())
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: {
id: "linear-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
const refresh = page.waitForRequest(
(request) =>
state.status === "needs_auth" &&
new URL(request.url()).pathname === "/api/mcp/resource" &&
request.method() === "GET",
)
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await refresh
await expect(toggle).toBeChecked()
await expect(toggle).toHaveAccessibleDescription("Sign in required")
} finally {
resources.resolve()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
})
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
await page.addInitScript(
({ directory, server }) => {
const current = { type: "http", http: { url: server }, displayName: "Design server" }
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
projects: { local: [{ worktree: directory, expanded: true }] },
hidden: {},
lastProject: {},
recentlyClosed: {},
}),
)
},
{
directory: fixture.directory,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
})
@@ -1,125 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
const services = [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
] as const
for (const service of services) {
for (const empty of [false, true]) {
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
const state = { hold: false }
const response = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === service.path,
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.hold) await response.promise
if (service.name === "LSP")
return route.fulfill({
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
})
const items =
service.name === "MCP"
? [{ name: service.item, status: { status: "connected" } }]
: service.name === "Plugins"
? [
{
id: service.item,
source: { type: "package", target: service.item },
features: {},
state: { status: "active" },
},
]
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const trigger = summary.getByRole("button", { name: service.name, exact: true })
await trigger.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
await expect(content).toBeVisible()
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
if (empty) {
const message = menu.locator(".session-service-empty")
await expect(message).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("gap", "8px")
await expect(message).toHaveCSS("font-size", "11px")
await expect(message).toHaveCSS("line-height", "16px")
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
state.hold = true
const refresh = page.waitForRequest(
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
)
try {
await trigger.click()
await refresh
await expect(menu).toHaveAttribute("aria-busy", "true")
await expect(content).toBeVisible()
await expect(menu.getByRole("status")).toHaveCount(0)
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(content).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
await mockStressTimeline(page)
const response = Promise.withResolvers<void>()
const state = { requested: false }
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
state.requested = true
await response.promise
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
try {
await expect.poll(() => state.requested).toBe(true)
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(menu.getByRole("status")).toContainText("Loading")
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
).toBeVisible()
})
@@ -1,249 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const layout of ["horizontal", "vertical"] as const) {
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.addInitScript((layout) => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({
...settings,
appearance: { ...settings.appearance, tabLayout: layout },
general: { ...settings.general, showStatus: true },
}),
)
}, layout)
await page.goto(stressSessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
const server = summary.getByRole("button", { name: "Server", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
for (const heading of [project, server]) {
await expect(heading).toHaveCSS("column-gap", "8px")
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
}
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
await project.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(server).toHaveAttribute("aria-expanded", "true")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
.toEqual({ projectExpanded: false, serverExpanded: false })
await page.goto(stressSessionHref(fixture.sourceID))
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Escape")
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
if (direction === "rtl") {
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
}
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
await mcp.hover()
await expect(submenu).toHaveCount(0)
await mcp.click()
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const row = await mcp.boundingBox()
const menu = await submenu.boundingBox()
if (!row || !menu) return false
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
})
.toBe(true)
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(submenu).toBeHidden()
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
await mcp.click()
await expect(submenu).toBeHidden()
for (const [name, text] of [
["Plugins", "No plugins configured yet"],
["Skills", "No skills configured yet"],
["LSP", "No LSP servers explicitly configured"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Server", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
for (const reviewOpen of [false, true]) {
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const header = await page.locator("[data-session-title]").boundingBox()
const panel = await summary.boundingBox()
if (!header || !panel) return Infinity
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
})
.toBeLessThanOrEqual(1)
await page.keyboard.press("Escape")
}
expect(warnings).toEqual([])
})
}
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const state = { fail: true, extra: false }
const requests: string[] = []
await page.route("**/api/plugin**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
{
id: "supermemory",
source: { type: "package", target: "opencode-supermemory" },
features: { server: true },
state: { status: "active" },
},
{
id: "broken-plugin",
source: { type: "local", path: "/broken.ts" },
features: { server: true },
state: { status: "failed", error: "Plugin failed to activate" },
},
...(state.extra
? [
{
id: "daytona",
source: { type: "package", target: "opencode-daytona" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
})
await page.route("**/api/skill**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
location: "/skills/review/SKILL.md",
content: "Review animations",
},
],
},
})
})
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
],
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
state.extra = true
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
await expect(plugins).toBeHidden()
await summary.getByRole("button", { name: "LSP", exact: true }).click()
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
})
@@ -188,8 +188,16 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
@@ -293,7 +301,7 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
@@ -326,23 +334,38 @@ for (const direction of ["ltr", "rtl"]) {
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(status).toHaveCount(0)
await expect(status).toHaveText("Status")
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect(status).toHaveCount(0)
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -356,9 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect(status).toHaveCount(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(settings).toHaveCount(0)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -17,14 +17,12 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import type { DraftMcpControls } from "./mcp"
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
mcp: DraftMcpControls
}) {
const route = useSessionKey()
const prompt = useComposerState()
@@ -51,7 +49,6 @@ export function createNewSessionComposerAdapter(props: {
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
const id = Session.ID.create()
const pending =
worktree === "create"
@@ -71,18 +68,6 @@ export function createNewSessionComposerAdapter(props: {
return
}
const rollback = async () => {
if (!pending) return
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
}
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
await rollback()
if (pending) props.mcp.remember(sessionDirectory, mcp)
return
}
const created = data.session.create({
id,
agent: selection.agent,
@@ -104,7 +89,10 @@ export function createNewSessionComposerAdapter(props: {
},
)
if (pending && !(await creation).ok) {
await rollback()
// Keep retries on the worktree that was already created, not another new checkout.
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
return
}
const afterCreation = async <T>(run: () => Promise<T>) => {
-117
View File
@@ -1,117 +0,0 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
import { showToast } from "@/shell/notifications/toast"
import { useWorkspaceLocation } from "@/workspaces/location"
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
const tabs = useTabs()
const location = useWorkspaceLocation()
const server = useServer()
const sdk = useServerSDK()
const language = useLanguage()
const [store, setStore] = createStore<{
preparing: boolean
pending: Record<string, Promise<boolean> | undefined>
}>({ preparing: false, pending: {} })
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
const target = createMemo(() => key(input.worktree()))
const preview = () => input.worktree() === "create"
const directory = createMemo(() => {
const selected = input.worktree()
return selected === "main" || selected === "create" ? location().directory : selected
})
const states = createMemo(() => {
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
})
const toggle = useMcpToggle(directory)
const controls: McpControls = {
get preview() {
return preview()
},
get states() {
return states()
},
get pending() {
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
},
change(name, enabled) {
if (controls.pending) return
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
if (preview()) return
const current = directory()
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
() => true,
() => false,
)
setStore("pending", current, request)
void request.finally(() => setStore("pending", current, undefined))
},
}
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
const pending = store.pending[directory]
if (pending && !(await pending)) return false
const entries = Object.entries(states)
if (entries.length === 0) return true
const catalog = await sdk.api.mcp.list({ location: { directory } })
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
const results = await Promise.all(
entries
.filter(([name, enabled]) => {
const server = catalog.data.find((server) => server.name === name)
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
})
.map(([name, enabled]) =>
toggle.mutateAsync({ name, enabled, directory }).then(
() => true,
() => false,
),
),
)
if (results.some((success) => !success)) return false
const current = await sdk.api.mcp.list({ location: { directory } })
const unresolved = entries.find(([name, enabled]) => {
const status = current.data.find((server) => server.name === name)?.status.status
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
})
if (!unresolved) return true
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
throw new Error(
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
name: unresolved[0],
}),
)
}
return {
controls,
directory,
capture: () => ({ ...states() }),
remember(directory: string, states: Readonly<Record<string, boolean>>) {
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
},
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
if (!store.pending[directory] && !Object.keys(states).length) return true
setStore("preparing", true)
return apply(directory, states)
.catch((error) => {
showToast({
variant: "error",
title: language.t("session.summary.mcp.prepareFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
.finally(() => setStore("preparing", false))
},
}
}
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
+5 -5
View File
@@ -1,18 +1,19 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionView } from "./view"
import { NewSessionStatus, NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
import { createDraftMcpControls } from "./mcp"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
@@ -30,13 +31,11 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
@@ -73,8 +72,9 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
)
-54
View File
@@ -1,54 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ProjectSummaryCard } from "@/session/summary/project-card"
import { SessionServerPanel } from "@/session/summary/server-panel"
import type { PromptProject } from "./project/selector"
import type { DraftMcpControls } from "./mcp"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { PromptWorkspaceSelector } from "./workspace/selector"
export function NewSessionSummary(props: {
project?: PromptProject
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
shown: boolean
onChooseProject: () => void
}) {
const language = useLanguage()
return (
<div data-component="session-summary-panel">
<Show
when={props.project}
fallback={
<div class="session-summary-card">
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
<Icon name="folder" class="text-v2-icon-icon-muted" />
{language.t("session.summary.chooseProject")}
</button>
</div>
}
>
{(project) => (
<>
<ProjectSummaryCard project={project()}>
<PromptWorkspaceSelector
variant="summary"
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onViewAll={props.workspace.project.openAll}
/>
</ProjectSummaryCard>
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
</>
)}
</Show>
</div>
)
}
+16 -30
View File
@@ -1,8 +1,7 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
import { createStore } from "solid-js/store"
import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
@@ -14,6 +13,8 @@ import {
PromptProjectSelector,
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -22,13 +23,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
import type { DraftMcpControls } from "./mcp"
const NewSessionSummary = lazy(async () => {
const { NewSessionSummary } = await import("./summary")
return { default: NewSessionSummary }
})
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -46,9 +40,7 @@ export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
}) {
const [store, setStore] = createStore({ summary: false })
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -69,25 +61,6 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div
data-slot="new-session-summary"
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
>
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
<Suspense>
<NewSessionSummary
project={props.project.selected()}
workspace={props.workspace}
mcp={props.mcp}
shown={store.summary}
onChooseProject={() => {
setStore("summary", false)
props.project.add()
}}
/>
</Suspense>
</SummaryPopover>
</div>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
@@ -142,6 +115,19 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
@@ -15,18 +15,13 @@ export function PromptWorkspaceSelector(props: {
branches: string[]
branch?: string
onboarding?: boolean
variant?: "inline" | "summary"
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone?: () => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const summary = () => props.variant === "summary"
const placement = createMemo(() =>
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
)
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
@@ -63,20 +58,17 @@ export function PromptWorkspaceSelector(props: {
props.onViewAll()
return
}
props.onDone?.()
props.onDone()
}
const label = () => {
if (selected() === "main")
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<Show when={!summary()}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
@@ -95,28 +87,18 @@ export function PromptWorkspaceSelector(props: {
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class={summary() ? "min-w-0 w-full" : "min-w-0"}
class="min-w-0"
>
<Menu
placement={placement()}
gutter={4}
overflowPadding={24}
modal={summary() ? false : undefined}
onOpenChange={onOpenChange}
>
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
<Menu.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon
name={icon()}
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
@@ -124,11 +106,7 @@ export function PromptWorkspaceSelector(props: {
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
/>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[200px]">
@@ -255,47 +233,22 @@ export function PromptWorkspaceSelector(props: {
</Tooltip>
<Show
when={selected() === "create" && props.branch}
fallback={
summary() ? (
<Show when={props.branch}>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{props.branch}
</span>
</div>
</Show>
) : (
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
)
}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
<Menu.Trigger
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
}
>
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
branch: props.branch!,
})}
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
/>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
+14 -26
View File
@@ -6,13 +6,6 @@ import { useServerSDK } from "@/runtime/server/client"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
export type McpControls = {
readonly preview: boolean
readonly states: Readonly<Record<string, boolean>>
readonly pending: boolean
change: (name: string, enabled: boolean) => void
}
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
const data = useData()
const serverSDK = useServerSDK()
@@ -24,36 +17,31 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
}
return useMutation(() => ({
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
const name = typeof input === "string" ? input : input.name
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
mutationFn: async (name: string) => {
const ref = location()
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
if (!server || (server.status.status === "pending" && typeof input === "string")) return
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
if (!enabled) {
if (!server || server.status.status === "pending") return
if (server.status.status === "connected") {
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
}
if (enabled && server.status.status !== "needs_auth") {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
await data.location.mcp.server.sync(ref)
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: current.integrationID,
integrationID: server.integrationID,
methodID: method.id,
location: ref,
})
platform.openExternal(attempt.data.url)
} else {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
data.location.mcp.resource.invalidate(ref)
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
// A successful HTTP response can still leave the MCP connection in a failed state.
const status = current?.status
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
},
onError: (error) =>
-27
View File
@@ -387,7 +387,6 @@ export const dict = {
"mcp.status.needs_auth": "needs auth",
"mcp.status.disabled": "disabled",
"mcp.auth.clickToAuthenticate": "Click to authenticate",
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
"dialog.fork.empty": "No messages to fork from",
@@ -1386,32 +1385,6 @@ export const dict = {
"session.summary.title": "Session details",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"session.summary.server": "Server",
"session.summary.chooseProject": "Choose a project",
"session.summary.mcp.onCreation": "Applies when the worktree is created",
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
"session.summary.mcp": "MCP",
"session.summary.plugins": "Plugins",
"session.summary.skills": "Skills",
"session.summary.lsp": "LSP",
"session.summary.failed": "Failed",
"session.summary.retry": "Retry",
"session.summary.connecting": "Connecting…",
"session.summary.needsAuth": "Sign in required",
"session.summary.mcp.empty": "No MCP servers configured yet",
"session.summary.mcp.add": "Add servers in opencode.json",
"session.summary.plugins.manage": "Manage plugins in opencode.json",
"session.summary.plugins.empty": "No plugins configured yet",
"session.summary.plugins.add": "Add plugins in opencode.json",
"session.summary.skills.manage": "Manage skills in opencode.json",
"session.summary.skills.empty": "No skills configured yet",
"session.summary.skills.add": "Add skills in opencode.json",
"session.summary.lsp.configured": "Configured LSPs",
"session.summary.lsp.empty": "No LSP servers explicitly configured",
"session.summary.lsp.manage": "Manage LSP in opencode.json",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create worktree",
@@ -1,12 +1,29 @@
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
const language = useLanguage()
const settings = useSettings()
export function SessionHeaderSpacer(props: { visible: boolean }) {
const isDesktop = createMediaQuery("(min-width: 768px)")
return (
<Show when={isDesktop() && props.visible}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
<>
<TitlebarRight>
<Show when={isDesktop() && settings.visibility.status()}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<Show when={isDesktop() && props.reserveReviewToggle}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
)
}
+24 -3
View File
@@ -15,6 +15,11 @@ import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
@@ -29,9 +34,11 @@ export function SessionMobileViewTabs(props: {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: false,
pending: undefined as "status" | "details" | undefined,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
@@ -88,18 +95,32 @@ export function SessionMobileViewTabs(props: {
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
setStore({ details: true, detailsLoaded: true, pending: false })
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: true, menu: false })}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
+1 -6
View File
@@ -12,7 +12,7 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -41,11 +41,6 @@ const SessionMobileFiles = lazy(async () => {
return { default: SessionMobileFiles }
})
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("./summary/panel")
return { default: SessionSummaryPanel }
})
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const server = useServer()
@@ -1,89 +0,0 @@
import { Popover } from "@kobalte/core/popover"
import { useData } from "@opencode/session-ui/context"
import { Icon } from "@opencode/ui/icon"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createEffect, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useLanguage } from "@/runtime/i18n/language"
export type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [store, setStore] = createStore({ open: false })
createEffect(() => {
if (props.tasks.length > 0) return
setStore("open", false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={store.open}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(open) => setStore("open", open)}
>
<Show when={props.tasks.length > 0}>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="session-summary-row"
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
>
<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", props.tasks.length)}
active
class="session-summary-label"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="session-service-menu"
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="session-service-row"
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()
setStore("open", false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0">{taskType(task)}</span>
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
@@ -1,38 +0,0 @@
import { expect, test } from "bun:test"
import { configuredLsps } from "./configured-lsp"
test("lists configured LSP names with project overrides and no inferred built-ins", () => {
expect(
configuredLsps([
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{
type: "document",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["vscode-eslint-language-server", "--stdio"] },
},
},
},
]),
).toEqual(["eslint", "rust"])
expect(configuredLsps([{ type: "document", info: { lsp: true } }])).toEqual([])
})
test("a later whole-LSP setting clears earlier names", () => {
expect(
configuredLsps([
{ type: "document", info: { lsp: { rust: { command: ["rust-analyzer"] } } } },
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual(["custom"])
})
@@ -1,17 +0,0 @@
import type { ConfigEntry } from "@opencode/client"
export function configuredLsps(entries: readonly ConfigEntry[]) {
return entries
.reduce<string[]>((names, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return names
const lsp = entry.info.lsp
if (typeof lsp === "boolean") return []
return [
...names.filter((name) => !Object.hasOwn(lsp, name)),
...Object.entries(lsp)
.filter(([, server]) => !server.disabled)
.map(([name]) => name),
]
}, [])
.toSorted((a, b) => a.localeCompare(b))
}
-130
View File
@@ -1,130 +0,0 @@
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { createMemo, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
import { SessionServerPanel } from "./server-panel"
import { ProjectSummaryCard } from "./project-card"
import "./summary.css"
export function SessionSummaryPanel(props: {
shown?: boolean
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const settings = useSettings()
const expanded = settings.sessionSummary.projectExpanded
const placement = createMemo(() =>
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
)
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
return (
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
<div>
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={placement()}
gutter={4}
class="session-summary-row"
>
<Icon name={props.local ? "monitor" : "outline-worktree"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{location()}
</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{props.branch}
</span>
</Show>
</div>
<button type="button" class="session-summary-row" onClick={props.onReview}>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</ProjectSummaryCard>
<Show when={expanded() && props.local && props.diffs?.length && props.moveEligible && !props.moveDismissed}>
<div class="session-summary-move">
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={placement()}
gutter={4}
class="session-summary-row"
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class="session-summary-dismiss"
aria-label={language.t("common.dismiss")}
onClick={props.onMoveDismiss}
>
<Icon name="xmark-small" />
</button>
</div>
</Show>
</div>
<SessionServerPanel directory={props.directory} shown={props.shown !== false} mobile={props.mobile} />
</div>
)
}
@@ -1,31 +0,0 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) {
const language = useLanguage()
return (
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={props.open ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={props.open}
/>
<Popover.Portal>
<Popover.Content
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
aria-label={language.t("session.summary.title")}
>
{props.children}
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
@@ -1,50 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import "./summary.css"
export function ProjectSummaryCard(
props: ParentProps<{
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
avatar?: JSX.Element
}>,
) {
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.projectExpanded
return (
<section class="session-summary-card" data-section="project">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-label={displayName(props.project)}
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{displayName(props.project)}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
</span>
</button>
<Show when={expanded()}>
<div id={contentID} class="session-summary-rows">
{props.children}
</div>
</Show>
</section>
)
}
@@ -1,441 +0,0 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import {
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Index,
on,
onCleanup,
Show,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { useSettings } from "@/settings/model"
import { pluginLabel } from "@/providers/catalog/plugin"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { configuredLsps } from "./configured-lsp"
const services = [
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
{ type: "skills", icon: "post-skill", label: "session.summary.skills" },
{ type: "lsp", icon: "code", label: "session.summary.lsp" },
] as const
type Service = (typeof services)[number]["type"]
type ServiceMenuProps = {
service: (typeof services)[number]
directory: string
shown: boolean
open: boolean
mobile?: boolean
mcp?: McpControls
onOpenChange: (open: boolean) => void
}
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.serverExpanded
const name = createMemo(() => {
const servers = global.servers.list()
if (servers.length < 2) return language.t("session.summary.server")
return serverName(servers.find((connection) => ServerConnection.key(connection) === server.key) ?? server.conn)
})
const [store, setStore] = createStore<{ submenu?: Service }>({})
createEffect(on([() => props.directory, () => props.shown, expanded], () => setStore("submenu", undefined)))
return (
<section class="session-summary-card" data-section="server">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
>
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{name()}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
</span>
</button>
<Show when={expanded() ? props.directory : undefined} keyed>
{(directory) => (
<div id={contentID} class="session-summary-rows">
<For each={services}>
{(service) => (
<ServiceMenu
service={service}
directory={directory}
shown={props.shown}
open={store.submenu === service.type}
mobile={props.mobile}
mcp={props.mcp}
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
/>
)}
</For>
</div>
)}
</Show>
</section>
)
}
function ServiceMenu(props: ServiceMenuProps) {
if (props.service.type === "mcp") return <McpMenu {...props} />
if (props.service.type === "lsp") return <LspMenu {...props} />
return <ServiceCatalog {...props} />
}
function LspMenu(props: ServiceMenuProps) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [load, { refetch }] = createResource(
() => props.shown && props.directory,
(directory) => {
data.location.config.invalidate({ directory })
return data.location.config.sync({ directory })
},
)
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
createEffect(() => {
onCleanup(sdk.event.location(props.directory).on("config.updated", () => void refetch()))
})
return (
<ServicePopover
{...props}
loading={load.loading}
ready={data.location.config.list({ directory: props.directory }) !== undefined}
empty={names().length === 0}
error={load.error}
retry={refetch}
>
<Show
when={names().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.lsp.empty")}
description={language.t("session.summary.lsp.manage")}
/>
}
>
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
<For each={names()}>
{(name) => (
<div class="session-service-row">
<span dir="auto" class="session-summary-label">
{name}
</span>
</div>
)}
</For>
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
</Show>
</ServicePopover>
)
}
function McpMenu(props: ServiceMenuProps) {
const data = useData()
const language = useLanguage()
const toggle = useMcpToggle(() => props.directory)
const [load, { refetch }] = createResource(
() => props.shown && ([props.directory, props.mcp?.preview] as const),
async ([directory, preview]) => {
data.location.mcp.server.invalidate({ directory })
await Promise.all([
data.location.mcp.server.sync({ directory }),
...(preview ? [data.location.config.sync({ directory })] : []),
])
},
)
const servers = createMemo(() =>
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const defaults = createMemo(() =>
Object.fromEntries(
(data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) =>
entry.type === "document"
? Object.entries(entry.info.mcp?.servers ?? {}).map(([name, config]) => [name, !config.disabled] as const)
: [],
),
),
)
return (
<ServicePopover
{...props}
loading={load.loading}
ready={
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
(!props.mcp?.preview || data.location.config.list({ directory: props.directory }) !== undefined)
}
empty={servers().length === 0}
error={load.error}
retry={refetch}
>
<Show
when={servers().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.mcp.empty")}
description={language.t("session.summary.mcp.add")}
/>
}
>
<Show when={props.mcp?.preview}>
<div class="session-service-message" data-slot="mcp-preview-hint">
{language.t("session.summary.mcp.onCreation")}
</div>
</Show>
<Index each={servers()}>
{(server) => {
const preview = () => props.mcp?.preview === true
const enabled = () =>
preview()
? (props.mcp?.states[server().name] ?? defaults()[server().name] ?? true)
: server().status.status !== "disabled"
const pending = () =>
(props.mcp?.pending ?? toggle.isPending) || (!preview() && server().status.status === "pending")
const error = () => {
const status = server().status
return status.status === "failed" ? status.error : undefined
}
const label = () => {
if (preview()) return undefined
const status = server().status.status
if (status === "failed") return language.t("session.summary.failed")
if (status === "pending") return language.t("session.summary.connecting")
if (status === "needs_auth") return language.t("session.summary.needsAuth")
return undefined
}
const change = (value: boolean) => {
if (pending()) return
if (props.mcp) return props.mcp.change(server().name, value)
toggle.mutate({ name: server().name, enabled: value })
}
return (
<Switch
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
description={preview() ? language.t("session.summary.mcp.onCreation") : label()}
checked={enabled()}
readOnly={pending()}
aria-disabled={pending()}
aria-busy={props.mcp?.pending ?? toggle.isPending}
onChange={change}
onClick={(event: MouseEvent) => {
if (event.target === event.currentTarget) change(!enabled())
}}
title={preview() ? server().name : (error() ?? server().name)}
>
<span
class="session-service-dot"
data-status={preview() ? undefined : server().status.status}
aria-hidden="true"
/>
<span dir="auto" class="session-summary-label">
{server().name}
</span>
<Show when={label()}>
{(status) => (
<span class="session-service-status" aria-hidden="true">
{status()}
</span>
)}
</Show>
</Switch>
)
}}
</Index>
</Show>
</ServicePopover>
)
}
function ServiceCatalog(props: ServiceMenuProps) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [items, { refetch }] = createResource(
() => props.shown && props.directory,
async (directory) => {
if (props.service.type === "plugins") {
const result = await sdk.api.plugin.list({ location: { directory } })
return result.data
.filter((plugin) => plugin.source.type !== "builtin")
.map((plugin) => ({
name: pluginLabel(plugin),
status: plugin.state.status,
error: plugin.state.status === "failed" ? plugin.state.error : undefined,
}))
}
data.location.skill.invalidate({ directory })
await data.location.skill.sync({ directory })
return undefined
},
)
const loaded = () => items.state === "ready" || items.state === "refreshing"
const list = createMemo(() => {
const entries =
props.service.type === "plugins"
? loaded()
? (items.latest ?? [])
: []
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
name: skill.name,
status: "active",
error: undefined,
}))
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
})
createEffect(() => {
onCleanup(
sdk.event
.location(props.directory)
.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
)
})
return (
<ServicePopover
{...props}
loading={items.loading}
ready={
props.service.type === "plugins"
? loaded()
: data.location.skill.list({ directory: props.directory }) !== undefined
}
empty={list().length === 0}
error={items.error}
retry={refetch}
>
<Show
when={list().length}
fallback={
<ServiceEmpty
title={language.t(
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
)}
description={language.t(
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
)}
/>
}
>
<div class="session-service-message">
{language.t(
props.service.type === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage",
)}
</div>
<For each={list()}>
{(item) => (
<div class="session-service-row" title={item.error ?? item.name}>
<span class="session-service-dot" data-status={item.status} aria-hidden="true" />
<span dir="auto" class="session-summary-label">
{item.name}
</span>
<Show when={item.status === "failed"}>
<span class="session-service-status">{language.t("session.summary.failed")}</span>
</Show>
</div>
)}
</For>
</Show>
</ServicePopover>
)
}
function ServicePopover(
props: ServiceMenuProps & {
loading: boolean
ready: boolean
empty: boolean
error: unknown
retry: () => unknown
children: JSX.Element
},
) {
const language = useLanguage()
const placement = createMemo(() =>
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
)
return (
<Popover
open={props.open}
onOpenChange={(open) => {
props.onOpenChange(open)
if (open && !props.loading) void props.retry()
}}
placement={placement()}
gutter={4}
overflowPadding={16}
modal={false}
>
<Popover.Trigger as="button" type="button" class="session-summary-row">
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t(props.service.label)}</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="session-service-menu"
data-service={props.service.type}
data-empty={(props.ready && !props.error && props.empty) || undefined}
aria-busy={props.loading}
aria-label={language.t(props.service.label)}
>
<Show
when={props.ready || !props.loading}
fallback={
<div class="session-service-message" role="status">
{language.t("common.loading")}
</div>
}
>
<Show
when={!props.error}
fallback={
<div class="session-service-message" role="alert">
<p>{language.t("common.requestFailed")}</p>
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
{language.t("session.summary.retry")}
</button>
</div>
}
>
{props.children}
</Show>
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function ServiceEmpty(props: { title: string; description: string }) {
return (
<div class="session-service-empty">
<strong>{props.title}</strong>
<p>{props.description}</p>
</div>
)
}
@@ -1,221 +0,0 @@
[data-component="session-summary-panel"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
max-width: calc(100vw - 32px);
&[data-mobile] {
width: 100%;
max-width: none;
}
}
.session-summary-card {
position: relative;
z-index: 1;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
[data-color-scheme="dark"] .session-summary-card {
background: var(--v2-background-bg-layer-01);
}
.session-summary-rows {
display: flex;
flex-direction: column;
}
.session-summary-row,
.session-service-row,
[data-component="switch"].session-mcp-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 12px;
border-radius: 4px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
text-align: start;
}
.session-summary-row:focus-visible,
.session-mcp-row:focus-within {
outline: none;
background: var(--v2-overlay-simple-overlay-hover);
}
@media (hover: hover) {
button.session-summary-row:hover,
.session-mcp-row:not([data-disabled]):hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
.session-summary-row[data-expanded] {
background: var(--v2-overlay-simple-overlay-pressed);
}
.session-summary-heading {
color: var(--v2-text-text-muted);
font-weight: 530;
}
.session-summary-heading-label {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.session-summary-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.session-summary-disclosure {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.session-summary-heading[aria-expanded="false"] .session-summary-disclosure {
transform: rotate(-90deg);
}
.session-summary-heading[aria-expanded="false"]:dir(rtl) .session-summary-disclosure {
transform: rotate(90deg);
}
.session-service-menu {
z-index: 60;
width: 280px;
max-width: calc(100vw - 32px);
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
outline: none;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
transform-origin: var(--kb-popover-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
&[data-empty] {
width: 200px;
padding: 0;
}
}
.session-service-empty {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px;
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
line-height: var(--line-height-compact);
overflow-wrap: anywhere;
strong {
font-weight: 530;
}
}
.session-service-message {
padding: 6px 12px;
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.session-service-dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--v2-icon-icon-faint);
}
.session-service-dot[data-status="connected"],
.session-service-dot[data-status="active"] {
background: var(--icon-success-base);
}
.session-service-dot[data-status="failed"] {
background: var(--icon-critical-base);
}
.session-service-dot[data-status="needs_auth"] {
background: var(--icon-warning-base);
}
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
height: auto;
align-self: stretch;
color: inherit;
font: inherit;
letter-spacing: inherit;
}
.session-service-status {
flex-shrink: 0;
font-size: 11px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
.session-mcp-row[aria-disabled="true"] [data-slot="switch-control"] {
opacity: 0.5;
}
.session-summary-move {
position: relative;
padding-top: 6px;
margin-top: -6px;
border-radius: 0 0 6px 6px;
background: var(--v2-background-bg-layer-02);
}
.session-summary-move > .session-summary-row {
padding-inline-end: 36px;
}
.session-summary-dismiss {
position: absolute;
inset-inline-end: 8px;
bottom: 6px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
border-radius: 4px;
}
.session-summary-dismiss:hover,
.session-summary-dismiss:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
@media (max-width: 767px) {
[data-component="session-summary-panel"] .session-summary-row,
.session-service-menu .session-service-row,
.session-service-menu .session-mcp-row {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.session-service-menu {
animation: none;
}
}
@@ -1,7 +1,5 @@
import { DataProvider } from "@opencode/session-ui/context"
import { BackgroundMoveHint } from "./message-timeline"
import { BackgroundWorkSummary } from "../summary/background"
import "../summary/summary.css"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
@@ -1,19 +1,11 @@
import {
createEffect,
createMemo,
createSignal,
lazy,
on,
onCleanup,
Show,
Suspense,
type Accessor,
type JSX,
} from "solid-js"
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"
import { IconButton } from "@opencode/ui/icon-button"
import { InlineInput } from "@opencode/ui/inline-input"
@@ -21,30 +13,34 @@ import { Keybind } from "@opencode/ui/keybind"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { SummaryPopover } from "../summary/popover"
import type { Project } from "@/runtime/server/types"
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 { useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline } from "@opencode/session-ui/timeline/projection"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import type { BackgroundTask } from "../summary/background"
import { SessionHeader } from "@/session/header/session-header"
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("../summary/panel")
return { default: SessionSummaryPanel }
})
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
@@ -74,6 +70,283 @@ 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))
const presence = createAnimatedPresence(
() => (props.tasks.length > 0 ? true : undefined),
() => triggerRef() ?? null,
)
createEffect(() => {
if (props.tasks.length > 0) return
setOpen(false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
>
<Show when={presence.present()}>
<Popover.Trigger
ref={setTriggerRef}
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
classList={{
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
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" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<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-[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>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
mobile?: boolean
eligible: boolean
sessionID: string
project: Project
directory: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ms-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
hidden: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={
props.mobile
? "top-end"
: inline()
? "bottom-end"
: language.direction() === "rtl"
? "right-start"
: "left-start"
}
gutter={props.mobile || inline() ? 4 : -22}
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "end-0 top-1/2"
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<Icon name="xmark-small" />
</button>
</div>
)
}
export function SessionSummaryPanel(props: {
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
const branch = () => props.branch ?? props.baseBranch
const row =
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{displayName(props.project)}
</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{branch()}
</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<div
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
classList={{
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
}}
>
<div class="min-h-0 overflow-hidden">
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</div>
</div>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
mobile={props.mobile}
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
@@ -546,32 +819,42 @@ function MessageTimelineView(
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
<Suspense>
<SessionSummaryPanel
shown={summaryOpen()}
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Suspense>
</SummaryPopover>
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
)}
</Show>
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
</div>
)}
</Show>
@@ -382,6 +382,18 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
+1 -12
View File
@@ -34,17 +34,6 @@ describe("settings timeline detail migration", () => {
})
describe("settings schema", () => {
test("restores summary expansion and discards the retired status preference", () => {
const settings = decode({
general: { showStatus: true, showSearch: true },
sessionSummary: { projectExpanded: false, serverExpanded: true },
})
expect(settings.general.showSearch).toBe(true)
expect(settings.general).not.toHaveProperty("showStatus")
expect(settings.sessionSummary).toEqual({ projectExpanded: false, serverExpanded: true })
expect(decode(encode(settings)).sessionSummary).toEqual(settings.sessionSummary)
})
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
@@ -69,6 +58,7 @@ describe("settings schema", () => {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: timelinePresets[2].value,
@@ -79,7 +69,6 @@ describe("settings schema", () => {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: {
fontSize: 14,
mono: "",
+8 -18
View File
@@ -79,6 +79,7 @@ const generalSchema = Persistence.struct({
showFileTree: Schema.Boolean,
showNavigation: Schema.Boolean,
showSearch: Schema.Boolean,
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
timelineDetail: Persistence.struct({
@@ -134,7 +135,6 @@ const soundsSchema = Persistence.struct({
export const settingsSchema = Persistence.struct({
general: generalSchema,
sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }),
appearance: appearanceSchema,
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
permissions: permissionsSchema,
@@ -243,6 +243,7 @@ export const defaultSettings: Settings = {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: { ...timelinePresets[2].value },
@@ -253,7 +254,6 @@ export const defaultSettings: Settings = {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
permissions: { autoApprove: false },
@@ -280,6 +280,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
@@ -321,6 +322,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus,
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
setShowProjectIcon(value: boolean) {
setStore("general", "showProjectIcon", value)
@@ -367,25 +372,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "experimentalBrowser", value)
},
},
sessionSummary: {
projectExpanded: withFallback(
() => store.sessionSummary?.projectExpanded,
defaultSettings.sessionSummary.projectExpanded,
),
serverExpanded: withFallback(
() => store.sessionSummary?.serverExpanded,
defaultSettings.sessionSummary.serverExpanded,
),
setProjectExpanded(value: boolean) {
setStore("sessionSummary", "projectExpanded", value)
},
setServerExpanded(value: boolean) {
setStore("sessionSummary", "serverExpanded", value)
},
},
visibility: {
fileTree: showFileTree,
search: showSearch,
status: showStatus,
customAgents: showCustomAgents,
},
appearance: {
+156
View File
@@ -0,0 +1,156 @@
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { pluginLabels } from "@/providers/catalog/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
const data = useData()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
return (
<div
class="flex items-center gap-1 rounded-xl"
classList={{
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
"w-full min-w-0": props.embedded,
}}
>
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-active="mcp"
defaultValue="mcp"
variant="underline"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpServers().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name()}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
{language.t("mcp.auth.clickToAuthenticate")}
</span>
</Show>
</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
/>
</div>
</button>
)
}}
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
)
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
"bg-icon-critical-base",
)
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -0,0 +1,34 @@
import type { LspStatus } from "@/runtime/server/types"
import type { McpServer } from "@opencode/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
if (input.connecting) return "bg-border-weak-base animate-pulse"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
@@ -0,0 +1,9 @@
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
align-items: center;
justify-content: center;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
@@ -0,0 +1,36 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
return { default: StatusPopoverBody }
})
export function StatusDrawer(props: {
open: boolean
onOpenChange: (open: boolean) => void
returnFocus?: () => HTMLElement | undefined
}) {
const language = useLanguage()
return (
<MobilePanelDrawer
title={language.t("status.popover.trigger")}
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
>
<Suspense
fallback={
<div data-slot="mobile-status-loading" role="status">
{language.t("common.loading")}
</div>
}
>
<Body shown={props.open} embedded />
</Suspense>
</MobilePanelDrawer>
)
}
@@ -0,0 +1,138 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Popover } from "@opencode/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown()} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: props.state.placement,
shift: props.state.shift,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={props.state.sidebar ? "button" : IconButton}
triggerProps={
props.state.sidebar
? {
type: "button",
class:
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
"data-state": props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
: {
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
}
trigger={
<>
<div class="relative size-4 shrink-0">
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
data-slot="status-indicator"
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
<Show when={props.state.sidebar}>
<span class="min-w-0 truncate">{props.state.label}</span>
</Show>
</>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
-1
View File
@@ -21,7 +21,6 @@ export const Draft = Persistence.struct({
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
})
const SessionCodec = Session.pipe(
-13
View File
@@ -16,19 +16,6 @@ function sessionTab(sessionId: string): SessionTab {
}
describe("tab migration", () => {
test("round trips draft MCP choices without changing older drafts", () => {
const legacy: Tab = { type: "draft", draftID: "legacy-draft", server, directory: "/project" }
const draft: Tab = {
...legacy,
draftID: "mcp-draft",
worktree: "create",
mcp: { target: "new-worktree", states: { first: true, second: false } },
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
expect(
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
+5 -4
View File
@@ -679,11 +679,12 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<Show when={updateState().visible}>
<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 />
</div>
</Show>
</Show>
</div>
</Portal>
)}
</Show>
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatTable(page.data)) + EOL
: formatList(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
}
+19 -1
View File
@@ -11,6 +11,7 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
@@ -26,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -437,6 +438,7 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -489,6 +491,15 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.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 permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1585,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1592,6 +1609,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -181,6 +181,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -395,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -1145,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1152,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -175,6 +175,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -565,6 +567,7 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1566,6 +1569,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+127 -42
View File
@@ -551,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1651,24 +1629,6 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1912,6 +1872,58 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2084,8 +2096,6 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2140,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2233,6 +2245,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2292,6 +2305,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2804,6 +2818,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2812,6 +2831,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2820,6 +2844,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2828,6 +2857,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2836,6 +2870,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2844,7 +2883,25 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2882,6 +2939,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3187,6 +3249,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3492,6 +3559,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -5753,6 +5825,19 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4
View File
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
+3
View File
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+10 -2
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -81,6 +82,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -157,6 +159,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -248,9 +254,10 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -387,6 +394,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
+2 -1
View File
@@ -1,6 +1,7 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -129,7 +130,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
+1
View File
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
@@ -116,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
+10
View File
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
+11
View File
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Permission } from "@opencode/schema/permission"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
+1
View File
@@ -103,6 +103,7 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+12 -1
View File
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: result.structured ?? (text === "" ? null : text),
output: output(),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
+74
View File
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+28
View File
@@ -224,6 +224,34 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
+1
View File
@@ -108,6 +108,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
+39 -2
View File
@@ -388,6 +388,32 @@ describe("Session.create", () => {
}),
)
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
const created = yield* session.create({ location, permissions })
expect(created.permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
expect(
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1330,7 +1356,12 @@ describe("SessionTransfer", () => {
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const template = yield* session.create({
location,
title: "Exported",
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1376,7 +1407,13 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
+10 -1
View File
@@ -1944,7 +1944,16 @@ describe("SessionRunnerLLM", () => {
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
})
scenario("rejects API instruction entries larger than 8KB", function* () {
scenario("accepts API instruction entries up to 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const value = "x".repeat(InstructionEntry.MaxValueBytes - 2)
yield* entries.put({ sessionID, key: "large", value })
expect(yield* entries.list(sessionID)).toEqual([{ key: "large", value }])
})
scenario("rejects API instruction entries larger than 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const exit = yield* entries
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
+1
View File
@@ -438,6 +438,7 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
@@ -132,4 +132,21 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+2
View File
@@ -27,6 +27,7 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
@@ -175,6 +176,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
+1 -1
View File
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 8 * 1024
export const MaxValueBytes = 256 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
"InstructionEntryValueTooLargeError",
+13
View File
@@ -25,6 +25,7 @@ import { TokenUsage } from "./token-usage.js"
import { SessionInbox } from "./session-inbox.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
import { Permission } from "./permission.js"
export { FileAttachment }
@@ -62,6 +63,7 @@ export const Created = Event.durable({
model: Model.Ref.pipe(optional),
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
metadata: SessionMetadata.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
version: Schema.String,
},
})
@@ -109,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const PermissionsUpdated = Event.durable({
type: "session.permissions.updated",
...options,
schema: {
...Base,
permissions: Permission.Ruleset,
},
})
export type PermissionsUpdated = typeof PermissionsUpdated.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
@@ -634,6 +646,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Viewed,
UsageUpdated,
Deleted,
+3
View File
@@ -10,6 +10,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionID } from "./session-id.js"
import { SessionMetadata } from "./session-metadata.js"
import { Money } from "./money.js"
import { Permission } from "./permission.js"
import { TokenUsage } from "./token-usage.js"
import { Revert } from "./session-revert.js"
import { SessionFork } from "./session-fork.js"
@@ -54,6 +55,8 @@ export const Info = Schema.Struct({
location: Location.Ref,
subpath: RelativePath.pipe(optional),
metadata: Metadata.pipe(optional),
/** Evaluated after the agent's rules; the last matching rule wins. */
permissions: Permission.Ruleset.pipe(optional),
revert: Revert.pipe(optional),
}).annotate({ identifier: "Session.Info" })
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.permissions.updated.1",
"session.viewed.1",
"session.message.content.updated.1",
"session.usage.recorded.1",
@@ -83,6 +83,15 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.permission.rules",
Effect.fn(function* (ctx) {
yield* sessions
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
+1
View File
@@ -120,6 +120,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
agent: ctx.payload.agent,
model: ctx.payload.model,
metadata: ctx.payload.metadata,
permissions: ctx.payload.permissions,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
@@ -39,6 +39,7 @@ describe("inference stat normalization", () => {
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
@@ -14,6 +14,7 @@ export const MODEL_AUTHOR_RULES = [
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
+2 -1
View File
@@ -51,7 +51,8 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
destination: "all-available",
selection: "clipboard",
})
+1
View File
@@ -274,6 +274,7 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.error": keybind("space", "View plugin error"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
@@ -223,6 +223,15 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: "view error",
command: "dialog.plugins.error",
hidden: !pluginError(focusedTui()),
onTrigger: (option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
},
},
{
title: toggleTitle(),
command: "plugins.toggle",
@@ -239,7 +248,7 @@ export function PluginsDialog(props: {
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -13,6 +13,10 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme"
// the context back, so the context must load first exactly as it does in the app.
import type { usePlugin } from "../../../src/plugin/context"
import "../../../src/plugin/context"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider } from "../../../src/context/data"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -32,11 +36,19 @@ function packagePlugin(outdated: boolean): PluginInfo {
}
}
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
async function renderPlugins(
root: string,
inventory: { list: PluginInfo[]; check: PluginInfo[] },
tui?: {
registered: { id: string; source: "builtin" | "external"; active: boolean }[]
list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[]
},
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const requests: { path: string; body: unknown }[] = []
const toasts: ToastOptions[] = []
const activations: string[] = []
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
const transport = createFetch(async (url, request) => {
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
@@ -49,13 +61,14 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return new Response(null, { status: 204 })
}
})
const api = createApi(transport.fetch)
function Harness() {
function Content() {
onCleanup(Keymap.use().mode.push("modal"))
const theme = useThemes().currentTokens()
const context = {
client: createApi(transport.fetch),
client: api,
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
get theme() {
return theme
@@ -66,9 +79,12 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
},
} as unknown as Context
const plugins = {
registered: () => [],
list: () => [],
activate: async () => true,
registered: () => tui?.registered ?? [],
list: () => tui?.list ?? [],
activate: async (id: string) => {
activations.push(id)
return true
},
deactivate: async () => true,
} as unknown as ReturnType<typeof usePlugin>
return <PluginsDialog context={context} plugins={plugins} />
@@ -77,15 +93,23 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={root}>
<LocationProvider>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
)
@@ -93,10 +117,46 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
return { app, requests, toasts }
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
await app.waitForFrame((frame) => frame.includes(expected))
return { app, requests, toasts, activations }
}
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(
tmp.path,
{ list: [], check: [] },
{
registered: [{ id: "broken.plugin", source: "external", active: false }],
list: [
{
target: "./broken.ts",
id: "broken.plugin",
status: "failed",
error: "Plugin setup failed",
},
],
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
expect(fixture.app.captureCharFrame()).toContain("enable")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.activations.length === 1)
expect(fixture.activations).toEqual(["broken.plugin"])
fixture.app.mockInput.pressKey(" ")
await fixture.app.waitForFrame(
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
)
} finally {
fixture.app.renderer.destroy()
}
})
test("checking for updates refreshes the inventory and reveals the update action", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
+13
View File
@@ -102,6 +102,19 @@ test("uses all available routes but skips the process host remotely", async () =
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("removes NUL characters before writing", async () => {
const writes: string[] = []
const clipboard = createClipboardAdapter(
coreClipboard({
onWrite: (text) => writes.push(text),
}),
)
expect(await clipboard.write("before\0after")).toBeUndefined()
expect(await clipboard.write("clean")).toBeUndefined()
expect(writes).toEqual(["beforeafter", "clean"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
+1 -1
View File
@@ -214,7 +214,7 @@ const icons = {
},
"fill-triangle-down": {
viewBox: "0 0 16 16",
body: `<path d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
body: `<path d="M5.37624 6.75194C5.1818 6.41861 5.42223 6 5.80813 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43199 10.5096C8.23905 10.8404 7.76115 10.8404 7.56821 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
},
archive: {
viewBox: "0 0 16 16",
@@ -656,6 +656,16 @@ const request = await ctx.permission.get({ sessionID, requestID })
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
```
Replace the session-scoped permission rules. They are evaluated after the agent's rules, and the
last matching rule wins. Child sessions inherit the rules in effect when they are created.
```ts
await ctx.permission.rules({
sessionID,
permissions: [{ action: "edit", resource: "/path/to/original/checkout/**", effect: "deny" }],
})
```
### Sessions
Create or read a session.
@@ -0,0 +1,548 @@
---
title: "commands"
description: "Reference for the opencode2 command line."
---
Every command accepts `--help` for its full flag list, for example `opencode2 run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
## run
`opencode2 run` sends a message and prints the reply without opening the interactive interface.
```bash
$ opencode2 run "Explain this repository"
```
Choose a model.
```bash
$ opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
```
Continue the last session.
```bash
$ opencode2 run --continue "Now handle the expired case"
```
Emit newline-delimited JSON for scripts.
```bash
$ opencode2 run --format json "List the TODO comments"
```
Attach files to the message.
```bash
$ opencode2 run --file src/server.ts --file src/client.ts "Review these for bugs"
```
Run with a specific agent.
```bash
$ opencode2 run --agent build "Fix the failing test"
```
View all subcommands and flags.
```bash
$ opencode2 run --help
```
## mini
`opencode2 mini` starts the minimal interactive interface instead of the full-screen TUI.
```bash
$ opencode2 mini
```
Continue the last session.
```bash
$ opencode2 mini --continue
```
Start with a model and an initial prompt.
```bash
$ opencode2 mini --model anthropic/claude-sonnet-4-5 --prompt "Summarize this repository"
```
View all subcommands and flags.
```bash
$ opencode2 mini --help
```
## session
`opencode2 session` manages sessions.
```bash
$ opencode2 session list
```
Limit the list and print JSON.
```bash
$ opencode2 session list --max-count 20 --format json
```
Delete a session and its child sessions.
```bash
$ opencode2 session delete ses_9c1b08
```
Export session data as JSON.
```bash
$ opencode2 session export ses_4f2a1c
```
Redact sensitive transcript and file data when exporting.
```bash
$ opencode2 session export ses_4f2a1c --sanitize
```
Import session data from a JSON file or URL.
```bash
$ opencode2 session import session.json
```
Import into a specific directory.
```bash
$ opencode2 session import session.json --directory ~/code/project
```
View all subcommands and flags.
```bash
$ opencode2 session --help
```
## auth
`opencode2 auth` manages AI providers and credentials.
```bash
$ opencode2 auth list
```
List them as JSON.
```bash
$ opencode2 auth list --format json
```
Log in to a provider.
```bash
$ opencode2 auth login anthropic
```
Log in with a specific authentication method.
```bash
$ opencode2 auth login anthropic --method api-key
```
Log out of a saved account.
```bash
$ opencode2 auth logout anthropic work
```
Switch the active account for an integration.
```bash
$ opencode2 auth switch anthropic work
```
View all subcommands and flags.
```bash
$ opencode2 auth --help
```
## models
`opencode2 models` lists every available model.
```bash
$ opencode2 models
```
View all subcommands and flags.
```bash
$ opencode2 models --help
```
## mcp
`opencode2 mcp` manages MCP (Model Context Protocol) servers.
```bash
$ opencode2 mcp list
```
Add a remote server.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp
```
Add a local server to the global config.
```bash
$ opencode2 mcp add everything --global -- npx -y @modelcontextprotocol/server-everything
```
Add a local server with an environment variable.
```bash
$ opencode2 mcp add everything --env LOG_LEVEL=debug -- npx -y @modelcontextprotocol/server-everything
```
Add a remote server with a header.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp --header CONTEXT7_API_KEY=secret
```
Authenticate with an OAuth-capable remote server.
```bash
$ opencode2 mcp auth sentry
```
Remove stored OAuth credentials for a server.
```bash
$ opencode2 mcp logout sentry
```
View all subcommands and flags.
```bash
$ opencode2 mcp --help
```
## plugin
`opencode2 plugin` manages plugins.
```bash
$ opencode2 plugin list
```
Include built-in server plugins.
```bash
$ opencode2 plugin list --builtin
```
Install a plugin and add it to the global configuration.
```bash
$ opencode2 plugin add @example/opencode-tui
```
Check package plugins for updates.
```bash
$ opencode2 plugin check
```
Update package plugins.
```bash
$ opencode2 plugin update
```
Remove a plugin from global configuration.
```bash
$ opencode2 plugin remove @example/opencode-tui
```
View all subcommands and flags.
```bash
$ opencode2 plugin --help
```
## stats
`opencode2 stats` shows shareable usage statistics.
```bash
$ opencode2 stats
```
Show the last 7 days.
```bash
$ opencode2 stats --days 7
```
Show model usage.
```bash
$ opencode2 stats --models
```
Show cost and token details.
```bash
$ opencode2 stats --cost
```
Print JSON instead of a report.
```bash
$ opencode2 stats --json
```
View all subcommands and flags.
```bash
$ opencode2 stats --help
```
## serve
`opencode2 serve` starts the API and web server. See [Web](/cli/web).
```bash
$ opencode2 serve
```
Bind to all interfaces on a fixed port.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
```
Allow a browser client from another origin.
```bash
$ opencode2 serve --cors https://app.example.com
```
View all subcommands and flags.
```bash
$ opencode2 serve --help
```
## pair
`opencode2 pair` shows server pairing information, including URLs, credentials, and a QR code.
```bash
$ opencode2 pair
```
Advertise an external URL in the QR code.
```bash
$ opencode2 pair --url https://dev.example.com
```
View all subcommands and flags.
```bash
$ opencode2 pair --help
```
## service
`opencode2 service` manages the background server. See [Web](/cli/web).
```bash
$ opencode2 service start
```
Restart it.
```bash
$ opencode2 service restart
```
Show its status.
```bash
$ opencode2 service status
```
Stop it.
```bash
$ opencode2 service stop
```
Read a setting.
```bash
$ opencode2 service get hostname
```
Set a setting.
```bash
$ opencode2 service set hostname 0.0.0.0
```
Allow an extra CORS origin.
```bash
$ opencode2 service set cors https://app.example.com
```
Pass an environment variable to the server process.
```bash
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Reset a setting to its default.
```bash
$ opencode2 service unset hostname
```
View all subcommands and flags.
```bash
$ opencode2 service --help
```
## api
`opencode2 api` makes a request to the running server.
```bash
$ opencode2 api GET /api/session
```
Call an operation ID with a query parameter.
```bash
$ opencode2 api v2.session.list --param limit=10
```
Send a JSON body.
```bash
$ opencode2 api v2.session.create --data '{"title": "New session"}'
```
Add a request header.
```bash
$ opencode2 api GET /api/session -H "accept: application/json"
```
View all subcommands and flags.
```bash
$ opencode2 api --help
```
## acp
`opencode2 acp` starts an Agent Client Protocol server over stdin and stdout for editor integrations. It runs until the client closes the connection.
```bash
$ opencode2 acp
```
View all subcommands and flags.
```bash
$ opencode2 acp --help
```
## debug
`opencode2 debug` provides debugging and troubleshooting tools.
```bash
$ opencode2 debug agents
```
List configuration sources.
```bash
$ opencode2 debug config
```
Show global paths.
```bash
$ opencode2 debug paths
```
Print a single path.
```bash
$ opencode2 debug paths db
```
View all subcommands and flags.
```bash
$ opencode2 debug --help
```
## upgrade
`opencode2 upgrade` upgrades OpenCode to the latest or a specific version. Alias: `update`.
```bash
$ opencode2 upgrade
```
Upgrade to a specific version with a specific package manager.
```bash
$ opencode2 upgrade 1.18.15 --method bun
```
View all subcommands and flags.
```bash
$ opencode2 upgrade --help
```
## uninstall
`opencode2 uninstall` removes OpenCode and all related files.
```bash
$ opencode2 uninstall
```
Preview what would be removed.
```bash
$ opencode2 uninstall --dry-run
```
Keep configuration and session data.
```bash
$ opencode2 uninstall --keep-config --keep-data
```
View all subcommands and flags.
```bash
$ opencode2 uninstall --help
```
+74
View File
@@ -0,0 +1,74 @@
---
title: "Web"
description: "Run OpenCode in the browser."
---
OpenCode ships with a web ui that is served from the same server that powers the
TUI. It's available by default and password protected.
## Access
```bash
$ opencode2 pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
```
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode2 service` command.
## Configure
Set any option with `opencode2 service set`:
```bash
# Listen on every network interface
$ opencode2 service set hostname 0.0.0.0
# Use a fixed port instead of the channel default
$ opencode2 service set port 49374
# Replace the generated password
$ opencode2 service set password "a-long-secret"
# Allow a web client served from another origin
$ opencode2 service set cors https://app.example.com,https://other.example.com
# Pass an environment variable to the server process
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Changing a setting stops the background server. To apply the new config
```bash
$ opencode2 service start
```
## Standalone
`opencode2 serve` runs the same server in the foreground instead of through the
shared background service.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
server listening on http://0.0.0.0:4096
server password <password>
```
Use it when you want to:
- Run OpenCode on a shared, always-on, or remote host, then connect clients with
`opencode2 --server <url>`.
- Control the hostname, port, and CORS origins for a single process.
- Run under a supervisor like systemd, Docker, or another environment that expects
a foreground process.
- Keep a dedicated server instead of the shared background service.
Connect a client to it with `--server`:
```bash
$ opencode2 --server http://127.0.0.1:4096
```
-18
View File
@@ -247,24 +247,6 @@ field, but it does not run formatters yet.
See the [formatters guide](/formatters) for accepted fields and current limitations.
### LSP
Define language server settings for compatibility and future use. V2 accepts
this field, but it does not start language servers yet.
```jsonc
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
},
},
}
```
See the [LSP guide](/lsp) for accepted fields and current limitations.
### Media
Control how oversized images loaded by the `read` tool are resized or rejected
-103
View File
@@ -1,103 +0,0 @@
---
title: "LSP"
---
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Callout type="warning">
OpenCode V2 does not yet have an LSP runtime or built-in language servers. The `lsp` configuration is accepted and
preserved, but it does not currently start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Callout>
## Built-in servers
There are no built-in LSP servers in the current V2 implementation. Setting
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
effect until V2 provides a server registry and LSP runtime.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
}
```
## Configuration
The `lsp` field accepts a boolean or an object keyed by server name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"env": {
"TSS_LOG": "-level verbose",
},
"initialization": {
"preferences": {
"importModuleSpecifierPreference": "relative",
},
},
},
},
}
```
Each enabled server entry has this shape:
| Property | Type | Required | Description |
| ---------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `command` | `string[]` | Yes | Executable followed by any arguments. |
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
| `disabled` | `boolean` | No | Disables the entry when `true`. |
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
The only entry that may omit `command` is the disable-only form:
```jsonc
{
"lsp": {
"typescript": {
"disabled": true,
},
},
}
```
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
including for a custom server, although a future runtime will need a way to
associate that server with files.
## Disable LSP
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
disable the whole integration, including when a lower-priority configuration
set it to `true` or supplied an object:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": false,
}
```
Use `{ "disabled": true }` under a server name to disable one server while
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
V2 currently performs no automatic LSP downloads.
## Current usage
V2 loads and validates the configuration shape for compatibility and future
integration. It does not currently use LSP when reading, writing, editing, or
patching files, and those tools do not notify a language server or return LSP
diagnostics.
For reliable feedback today, have the agent run the project's lint, typecheck,
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
skill so the agent knows when and where to run them.
+2 -32
View File
@@ -1,35 +1,5 @@
---
title: "Session sharing"
title: "Sharing"
---
Session sharing is not yet available in OpenCode V2. V2 does not currently
publish sessions, upload conversation history to a sharing service, or create
public links.
There is no functional share or unshare server API endpoint.
## Configuration
The V2 configuration schema accepts a `share` field with three values:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"share": "manual",
}
```
- `"manual"` represents sharing only when explicitly requested.
- `"auto"` represents automatically sharing new sessions.
- `"disabled"` represents preventing session sharing.
These values are parsed but are not acted on by the current V2 runtime. In
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
V2 leaves the sharing policy unspecified.
## Beta limitations
V2 currently provides no public session viewer, share URL, history sync,
retention controls, or unshare/delete operation. Until those surfaces are
implemented in the V2 server and protocol, keep using sessions locally and do
not treat the `share` configuration field as a privacy or publishing control.
OpenCode V2 does not support session sharing yet.
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: "Session warming"
title: "Warming"
---
Session warming sends periodic model requests for recently active sessions.
+4 -8
View File
@@ -30,7 +30,6 @@ export const docsSections: DocsSection[] = [
{
title: "Configure",
items: [
{ title: "LSP", slug: "lsp" },
{ title: "Agents", slug: "agents" },
{ title: "Models", slug: "models" },
{ title: "Skills", slug: "skills" },
@@ -46,8 +45,8 @@ export const docsSections: DocsSection[] = [
{ title: "MCP servers", slug: "mcp-servers" },
{ title: "Permissions", slug: "permissions" },
{ title: "Instructions", slug: "instructions" },
{ title: "Session sharing", slug: "sharing" },
{ title: "Session warming", slug: "warming" },
{ title: "Sharing", slug: "sharing" },
{ title: "Warming", slug: "warming" },
],
},
{
@@ -67,11 +66,8 @@ export const docsSections: DocsSection[] = [
items: [
{ title: "Intro", slug: "cli" },
{ title: "Config", slug: "cli/config" },
],
},
{
title: "Configure",
items: [
{ title: "Web", slug: "cli/web" },
{ title: "Commands", slug: "cli/commands" },
{ title: "Theme", slug: "cli/theme" },
{ title: "Plugins", slug: "cli/plugins" },
{ title: "Keybinds", slug: "cli/keybinds" },