Compare commits

...
76 changed files with 1764 additions and 468 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -119,7 +119,7 @@ jobs:
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
@@ -137,7 +137,7 @@ jobs:
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
}
}
+2
View File
@@ -1,3 +1,5 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
+37
View File
@@ -0,0 +1,37 @@
# Component browser tests
These tests exercise production Solid components through their existing Storybook stories. Unlike `e2e/`, they do not boot the app, configure a server, seed browser storage, or navigate through unrelated routes.
```sh
# Start Storybook automatically and run all component tests.
bun run test:components
# Run one component spec.
bun run test:components -- component-tests/session-timeline.spec.ts
# Explore the suite in Playwright's UI.
bun run test:components:ui
```
The tests are deliberately separate from `bun run test:e2e`, so CI can run app-wide user journeys without running component appearance and interaction coverage. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port.
## Adding a test
Keep scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; the test owns user-visible interactions and assertions.
```ts
import { expect, story } from "./story"
story("preserves collapsed state while a tool completes", async ({ mount }) => {
const component = await mount("current-session-context-projection--collapsed-during-status-updates")
const trigger = component.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
```
The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect and interact with exactly the scenario the browser test covers.
Keep cross-route navigation, remote-server ownership, persistent session state, and workflows spanning independent surfaces in `e2e/`.
@@ -0,0 +1,26 @@
import { expect, story } from "./story"
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
})
@@ -0,0 +1,84 @@
import { expect, story } from "./story"
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
const timeline = await mount(
`current-session-tool-projection--${expanded ? "expanded-shell-updates" : "collapsed-shell-updates"}`,
)
const trigger = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Update output" }).click()
await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Run command" }).click()
await timeline.getByRole("button", { name: "Complete command" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions a streaming shell from writing through command execution", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle")
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const shimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shimmer).toHaveAttribute("aria-label", "Shell")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
await timeline.getByRole("button", { name: "Run command" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Complete command" }).click()
await expect(subtitle).toHaveText("printf ready")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("shimmers and expands a running shell command", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle")
await timeline.getByRole("button", { name: "Run command" }).click()
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const trigger = tool.locator('[data-slot="collapsible-trigger"]')
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText("printf ready")
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(trigger).toHaveCSS("height", "28px")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
for (const profile of [
{ locale: "de", story: "completed-german", label: "Erkundung abgeschlossen" },
{ locale: "ar", story: "completed-arabic", label: "تم الاستكشاف" },
] as const) {
// Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
story(`projects translated context status in ${profile.locale}`, async ({ mount, page }) => {
const timeline = await mount(`current-session-context-projection--${profile.story}`)
await timeline.getByRole("button", { name: "Complete read" }).click()
await timeline.getByRole("button", { name: "Complete glob" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
})
}
@@ -0,0 +1,51 @@
import { expect, story } from "./story"
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when a line number is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible()
const numbers = review.locator('[data-column-number="1"]')
await expect(numbers).toHaveCount(2)
const number = numbers.nth(1)
await number.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor for a line number range", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const first = review.locator('[data-column-number="1"]')
const last = review.locator('[data-column-number="3"]')
await expect(first).toHaveCount(2)
await expect(last).toHaveCount(2)
await first.nth(1).dragTo(last.nth(1))
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("shows a comment button when a diff line is hovered", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -0,0 +1,42 @@
import { expect, story } from "./story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
const timeline = await mount("current-session-timeline-rows--moved-location")
const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
@@ -0,0 +1,55 @@
import { expect, story } from "./story"
const profiles = [
{ name: "summaries off no reasoning", id: "summaries-off-no-reasoning", thinking: true, body: false },
{
name: "summaries off reasoning heading",
id: "summaries-off-reasoning-heading",
thinking: true,
body: false,
heading: true,
},
{
name: "summaries off with visible tool",
id: "summaries-off-with-visible-tool",
thinking: true,
body: false,
heading: true,
},
{ name: "summaries on no content", id: "summaries-on-no-content", thinking: true, body: false },
{ name: "summaries on blank reasoning", id: "summaries-on-blank-reasoning", thinking: true, body: false },
{
name: "summaries on visible reasoning",
id: "summaries-on-visible-reasoning",
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
id: "summaries-on-visible-tool-no-reasoning",
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
const timeline = await mount(`current-session-reasoning-projection--${profile.id}`)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
profile.body ? 1 : 0,
)
if ("heading" in profile) {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story("does not infer reasoning visibility from provider identity", async ({ mount }) => {
const timeline = await mount("current-session-reasoning-projection--provider-without-reasoning")
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible()
})
@@ -0,0 +1,53 @@
import { expect, story } from "./story"
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves a collapsed context group through count and status updates", async ({ mount }) => {
const timeline = await mount("current-session-context-projection--collapsed-during-status-updates")
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete glob" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
// Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts
story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
const timeline = await mount("current-session-terminal-work--collapsed-shell")
const trigger = timeline.locator('[data-timeline-part-id="tool_terminal_passed"] [data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
const before = await page.evaluate(() => window.scrollY)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await page.evaluate(() => window.scrollY)).toBe(before)
})
// Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts
story("renders a completed write through the production file component", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--created-a-new-file")
await expect(timeline.locator('[data-component="write-content"]')).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts
story("keeps patch file disclosures independent", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--patched-two-files")
const files = timeline.locator('[data-scope="apply-patch"] button')
await expect(files).toHaveCount(2)
await expect(files.nth(0)).toHaveAttribute("aria-expanded", "false")
await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false")
await files.nth(0).click()
await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true")
await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false")
await files.nth(1).click()
await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true")
await expect(files.nth(1)).toHaveAttribute("aria-expanded", "true")
})
@@ -0,0 +1,177 @@
import { expect, story } from "./story"
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--every-tool-family")
await expect(
timeline.locator('[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list"]'),
).toBeVisible()
for (const id of [
"webfetch",
"websearch",
"subagent",
"shell",
"edit",
"write",
"patch",
"question",
"skill",
"custom",
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--every-tool-error")
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
await expect(timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')).toContainText(/dismissed/i)
await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0)
for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("transitions shell and question through running error outcomes", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--running-tool-errors")
const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]')
const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]')
await expect(shell).toBeVisible()
await expect(question).toHaveCount(0)
await timeline.getByRole("button", { name: "Fail running tools" }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toBeVisible()
await expect(shell).toContainText("Command exited 1")
await expect(question).toContainText(/dismissed/i)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels all web search provider variants", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--search-providers")
await expect(timeline.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(timeline.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(timeline.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels completed searches with result counts", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--context-labels")
const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels read tools from their path input", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--context-labels")
const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--skill-labels")
for (const [id, name] of [
["tool_skill_id", "frontend-design"],
["tool_skill_name", "OpenCode"],
] as const) {
const skill = timeline.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
}
})
// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
story("groups singleton and separated context operations at correct boundaries", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--context-boundaries")
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_list"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--grouped-edits")
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]')
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("combines adjacent patch calls and repeated files into one group", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--grouped-patch-updates")
const first = timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]')
const file = first.locator('[data-scope="apply-patch"] [data-type="update"] button')
await expect(file).toBeVisible()
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await first.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.patchRow = "stable"
})
await timeline.getByRole("button", { name: "Append patch" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]')
await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable")
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true")
await timeline.getByRole("button", { name: "Complete patch" }).click()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-type="add"] button')).toHaveAttribute("aria-expanded", "false")
await expect(
timeline.locator(
'[data-timeline-part-id="tool_grouped_patch_first"], [data-timeline-part-id="tool_grouped_patch_second"]',
),
).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("preserves surviving grouped patch state when its first patch fails", async ({ mount }) => {
const timeline = await mount("current-session-tool-projection--grouped-patch-failure")
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]')
const file = group.locator('[data-scope="apply-patch"] button').filter({ hasText: "surviving.ts" })
await expect(file).toBeVisible()
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
await timeline.getByRole("button", { name: "Fail first patch" }).click()
const failed = timeline.locator("[data-timeline-key]", {
has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]'),
})
const surviving = timeline.locator("[data-timeline-key]", {
has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_second"]'),
})
await expect(failed).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(surviving).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failed.getByText("Patch failed visibly")).toBeVisible()
await expect(surviving).toHaveAttribute("data-group-identity", "preserved")
await expect(surviving.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test"
import type { Locator } from "@playwright/test"
export { expect }
export const story = test.extend<{ mount: (id: string) => Promise<Locator> }>({
mount: async ({ page }, use) => {
await use(async (id) => {
await page.goto(`/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`)
const root = page.locator("#storybook-root")
await expect(root).toBeVisible({ timeout: 30_000 })
return root
})
},
})
@@ -1,81 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-thinking-level-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"thinking-model": {
id: "thinking-model",
name: "Thinking Model",
limit: { context: 200_000 },
variants: { high: {} },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "thinking-model" },
},
sessions: [
{
id: sessionID,
slug: "prompt-thinking-level-regression",
projectID,
directory,
title: "Prompt thinking level regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeVisible()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
@@ -1,22 +0,0 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
const shellID = "prt_space_button_shell"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
await trigger.focus()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
@@ -1,31 +0,0 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = {
read: { path: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
{ completed: false },
),
],
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
@@ -8,21 +8,6 @@ import {
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
})
test("renders a completed single-file patch", async ({ page }) => {
const id = "prt_file_projection_single_patch"
await setupTimeline(page, {
@@ -1,53 +0,0 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
]),
],
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}
@@ -185,57 +185,6 @@ test("shows a delegating row while subagent input streams", async ({ page }) =>
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
@@ -1,94 +0,0 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
reasoningPart,
setupTimeline,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "## Inspecting stability",
other: false,
thinking: true,
body: false,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "## Inspecting stability",
other: true,
thinking: true,
body: false,
},
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: " ",
other: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "## Inspecting stability",
other: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "",
other: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
const parts = [
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
...(profile.other
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
: []),
]
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts, { completed: false })],
settings: { showReasoningSummaries: profile.summaries },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
],
settings: { showReasoningSummaries: true },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
})
+1 -1
View File
@@ -7,5 +7,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
}
+2
View File
@@ -26,6 +26,8 @@
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006)
const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}`
export default defineConfig({
testDir: "./component-tests",
outputDir: "./component-tests/test-results",
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [["html", { outputFolder: "component-tests/playwright-report", open: "never" }], ["line"]],
webServer: {
command: `bun --bun run --cwd ../storybook storybook -- --port ${port} --ci --no-open`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }],
})
@@ -11,7 +11,8 @@ import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { LocationProvider } from "@/workspaces/location"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { createEditProjectModel } from "./project-model"
import { ProjectSettingsExtensions } from "./project-extensions"
import { SettingsServerDataScope } from "@/settings/server-scope"
@@ -57,11 +58,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Tabs.List>
<div class="project-settings-nav">
<Tabs.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0"
/>
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
<span class="truncate">{projectName()}</span>
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
@@ -114,14 +111,14 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
}}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
@@ -1,13 +1,12 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { InlineServerSelect } from "@/settings/server-select"
import { DialogEditProject } from "./project-dialog"
import "@/settings/settings.css"
@@ -40,14 +39,13 @@ export const SettingsProjects: Component = () => {
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
const color = () => getProjectAvatarVariant(props.project.icon?.color)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
<ProjectIcon project={props.project} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -0,0 +1,24 @@
import { ProjectAvatar, type ProjectAvatarProps } from "@opencode-ai/ui/project-avatar"
import { splitProps } from "solid-js"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
project: LocalProject
fallback?: string
icon?: LocalProject["icon"]
}
export function ProjectIcon(props: ProjectIconProps) {
const [local, rest] = splitProps(props, ["project", "fallback", "icon"])
const icon = () => local.icon ?? local.project.icon
return (
<ProjectAvatar
{...rest}
fallback={local.fallback ?? displayName(local.project)}
src={getProjectAvatarSource(local.project.id, icon())}
variant={getProjectAvatarVariant(icon()?.color)}
/>
)
}
@@ -476,7 +476,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
@@ -2740,6 +2740,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3015,6 +3016,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3290,6 +3292,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
+1 -1
View File
@@ -108,7 +108,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
+22 -12
View File
@@ -596,7 +596,11 @@ const layer = Layer.effect(
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skills = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
@@ -718,7 +722,7 @@ const layer = Layer.effect(
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
@@ -970,16 +974,22 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
const available = yield* skillService.list()
return yield* Effect.forEach(requested, (attachment) => {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
return Effect.succeed({
id: skill.id,
name: skill.name,
mention: attachment.mention,
})
})
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
})
+5 -1
View File
@@ -139,7 +139,11 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
const skills =
message.skills?.flatMap((skill) =>
skill.text === undefined ? [] : [`[Skill activated: ${skill.name}]\n${skill.text}`],
) ?? []
return [...skills, `[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -236,6 +236,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]
+1
View File
@@ -220,6 +220,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: skill.text === undefined ? undefined : redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,
+20
View File
@@ -1,6 +1,7 @@
export * as Skill from "./skill.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { FSUtil } from "@opencode-ai/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
@@ -52,6 +53,21 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, 10)
: []
return {
directory,
output: toModelOutput(skill, files),
}
})
export type Data = {
skills: Map<ID, Types.DeepMutable<Info>>
}
@@ -64,6 +80,7 @@ export type Draft = {
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -98,6 +115,9 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
get: Effect.fn("Skill.get")(function* (id) {
return state.get().skills.get(id)
}),
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
}),
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+2 -17
View File
@@ -1,7 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -9,7 +8,6 @@ import { Skill } from "../../skill.js"
import { Permission } from "../../permission.js"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
@@ -47,8 +45,7 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.id === input.id)
const skill = yield* skills.get(input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
@@ -59,19 +56,7 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: Skill.toModelOutput(skill, files),
}
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}).pipe(
Effect.map((output) => ({
+42
View File
@@ -1,5 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -221,6 +222,47 @@ describe("Npm.add", () => {
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
})
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const repository = pathToFileURL(fixture.repository).href
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
const second = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
})
})
describe("Npm.resolve", () => {
@@ -23,6 +23,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -231,6 +232,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Manual compaction should include this short conversation.",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Use Effect services and generators.",
},
],
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
@@ -261,6 +269,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
+13 -1
View File
@@ -5,6 +5,7 @@ import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@@ -1178,6 +1179,13 @@ describe("SessionTransfer", () => {
id: sourceMessageID,
type: "user",
text: "Imported message",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Private skill instructions from /private/project",
},
],
time: { created: DateTime.makeUnsafe(100) },
},
{
@@ -1207,7 +1215,11 @@ describe("SessionTransfer", () => {
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{
id: sourceMessageID,
text: `[redacted:text:${sourceMessageID}]`,
skills: [{ id: "effect", name: "[redacted:skill-name:0]", text: "[redacted:skill:0]" }],
},
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
@@ -205,6 +205,44 @@ Recent work
})
})
test("lowers each prepared skill once before the prompt", () => {
const effect = SkillAttachment.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "<skill_content>Use Effect</skill_content>",
})
const api = SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "<skill_content>Design APIs</skill_content>",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill-content"),
type: "user",
text: "Use @effect and @api-design",
skills: [effect, api, SkillAttachment.make({ id: effect.id, name: effect.name })],
time: { created },
}),
],
model,
)
expect(messages).toEqual([
Message.make({
id: id("user-skill-content"),
role: "user",
content: [
{ type: "text", text: "<skill_content>Use Effect</skill_content>" },
{ type: "text", text: "<skill_content>Design APIs</skill_content>" },
{ type: "text", text: "Use @effect and @api-design" },
],
metadata: {},
}),
])
})
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
+57 -17
View File
@@ -9,6 +9,7 @@ import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -23,18 +24,20 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
}),
]),
const info = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
const skills = Layer.merge(
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
@@ -56,7 +59,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("keeps skill mentions as references on a normal prompt", () =>
it.effect("materializes mentioned skills on their owning prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -67,26 +70,63 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
text: "Apply @effect and @effect",
skills: [
{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } },
{ id: Skill.ID.make("effect"), mention: { start: 18, end: 25, text: "@effect" } },
],
resume: false,
})
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([])
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
expect.objectContaining({
id,
type: "user",
text: "Apply @effect",
text: "Apply @effect and @effect",
skills: [
{
id: "effect",
name: "Effect",
text: Skill.toModelOutput(info, []),
mention: { start: 6, end: 13, text: "@effect" },
},
{
id: "effect",
name: "Effect",
mention: { start: 18, end: 25, text: "@effect" },
},
],
}),
)
])
}),
)
it.effect("excludes mentioned skills when forking before their prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const session = yield* sessions.create({ location })
const initial = SessionMessage.ID.make("msg_before_skill_attachment")
const selected = SessionMessage.ID.make("msg_fork_skill_attachment")
yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false })
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
yield* sessions.prompt({
id: selected,
sessionID: session.id,
text: "Apply @effect",
skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
expect.objectContaining({ type: "user", text: "Before the skill" }),
])
}),
)
+2
View File
@@ -31,6 +31,8 @@ describe("Skill", () => {
})
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
expect(yield* skill.get(Skill.ID.make("review"))).toEqual(info("review", "Second"))
expect(yield* skill.get(Skill.ID.make("missing"))).toBeUndefined()
}),
)
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+1
View File
@@ -74,6 +74,7 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
get: (id) => Effect.succeed(current.find((skill) => skill.id === id)),
list: () => Effect.succeed(current),
}),
)
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+1
View File
@@ -57,6 +57,7 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String.pipe(optional),
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
@@ -6,6 +6,7 @@ import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
import { SkillAttachment } from "../src/prompt.js"
import { Provider } from "../src/provider.js"
import { Pty } from "../src/pty.js"
import { Session } from "../src/session.js"
@@ -79,6 +80,16 @@ describe("contract hygiene", () => {
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
})
test("skill attachments retain legacy references while accepting prepared instructions", () => {
const reference = { id: Skill.ID.make("effect"), name: Skill.Name.make("Effect") }
expect(Schema.decodeUnknownSync(SkillAttachment)(reference)).toEqual(reference)
expect(Schema.encodeSync(SkillAttachment)({ ...reference, text: undefined })).toEqual(reference)
expect(Schema.decodeUnknownSync(SkillAttachment)({ ...reference, text: "Use Effect" })).toEqual({
...reference,
text: "Use Effect",
})
})
test("session inbox items omit the internal enqueue sequence", () => {
expect(
Schema.encodeSync(SessionInbox.Info)(
@@ -1,6 +1,7 @@
import { createStore } from "solid-js/store"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { SessionReview } from "./session-review"
import { SessionReview, type SessionReviewComment } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -44,3 +45,35 @@ export const UnifiedDark = {
globals: { theme: "dark" },
render: () => <ReviewStory />,
}
function InteractiveCommentsStory() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diffs = [
{
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
]
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-hidden bg-background-base">
<SessionReview
title="Changes in this Session"
diffs={diffs}
open={[file]}
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
@@ -0,0 +1,83 @@
import type { SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures"
import { SessionTimeline } from "./session-timeline"
export default {
title: "OpenCode/Conversation/Context projection",
id: "current-session-context-projection",
component: SessionTimeline,
parameters: {
layout: "fullscreen",
docs: {
description: {
component: "Interactive context-group state transitions through the real production timeline.",
},
},
},
}
function ContextStatusStory() {
const [state, setState] = createStore({ read: false, glob: false })
const tool = (name: "read" | "glob", completed: boolean) => {
const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" }
return {
type: "tool",
id: `tool_context_${name}`,
name,
state: completed
? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} }
: { status: "running", input, metadata: {} },
time: {
created: STORY_TIME,
ran: STORY_TIME + 100,
...(completed ? { completed: STORY_TIME + 200 } : {}),
},
} satisfies SessionMessageAssistantTool
}
const document = createMemo(
() =>
({
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_context_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [tool("read", state.read), tool("glob", state.glob)],
time: {
created: STORY_TIME,
...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}),
},
} satisfies SessionMessageAssistant,
],
status: { type: state.read && state.glob ? "idle" : "busy" },
diffs: [],
}) satisfies SessionDocument,
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-2">
<button type="button" onClick={() => setState("read", true)}>
Complete read
</button>
<button type="button" onClick={() => setState("glob", true)}>
Complete glob
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
export const CollapsedDuringStatusUpdates = { render: () => <ContextStatusStory /> }
export const CompletedGerman = { globals: { locale: "de" }, render: () => <ContextStatusStory /> }
export const CompletedArabic = { globals: { locale: "ar" }, render: () => <ContextStatusStory /> }
@@ -0,0 +1,82 @@
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import type { SessionDocument } from "../document"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures"
import { SessionTimeline } from "./session-timeline"
export default {
title: "OpenCode/Conversation/Reasoning projection",
id: "current-session-reasoning-projection",
component: SessionTimeline,
parameters: {
layout: "fullscreen",
docs: {
description: {
component: "Busy reasoning, thinking, and tool visibility rendered directly by the production timeline.",
},
},
},
}
function ReasoningProjection(props: { summaries: boolean; reasoning?: string; tool?: boolean; text?: string }) {
const content = [
...(props.reasoning === undefined
? []
: [
{
type: "reasoning" as const,
text: props.reasoning,
time: { created: STORY_TIME + 100 },
},
]),
...(props.tool
? [
{
type: "tool" as const,
id: "tool_reasoning_projection_skill",
name: "skill",
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
},
]
: []),
...(props.text === undefined ? [] : [{ type: "text" as const, text: props.text }]),
] satisfies SessionMessageAssistant["content"]
const assistant = {
id: "msg_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME },
} satisfies SessionMessageAssistant
const document = {
sessionID: CURRENT_SESSION_ID,
messages: [...thinkingDocument.messages, assistant],
status: { type: "busy" },
diffs: [],
} satisfies SessionDocument
return (
<section class="mx-auto w-full max-w-[720px] p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
</CurrentSessionProviders>
</section>
)
}
export const SummariesOffNoReasoning = { render: () => <ReasoningProjection summaries={false} /> }
export const SummariesOffReasoningHeading = {
render: () => <ReasoningProjection summaries={false} reasoning="## Inspecting stability" />,
}
export const SummariesOffWithVisibleTool = {
render: () => <ReasoningProjection summaries={false} reasoning="## Inspecting stability" tool />,
}
export const SummariesOnNoContent = { render: () => <ReasoningProjection summaries /> }
export const SummariesOnBlankReasoning = { render: () => <ReasoningProjection summaries reasoning=" " /> }
export const SummariesOnVisibleReasoning = {
render: () => <ReasoningProjection summaries reasoning="## Inspecting stability" />,
}
export const SummariesOnVisibleToolNoReasoning = { render: () => <ReasoningProjection summaries tool /> }
export const ProviderWithoutReasoning = { render: () => <ReasoningProjection summaries text="No reasoning payload" /> }
@@ -62,6 +62,17 @@ export const UserCommandCompleted = {
),
}
export const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
title="Collapsed completed shell"
description="A focused shell disclosure responds to the keyboard without scrolling its containing surface."
document={terminalPassedDocument}
width="720px"
/>
),
}
export const TestsPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -156,6 +156,29 @@ export const MixedDirectionRtl = {
),
}
export const MovedLocation = {
render: () => (
<CurrentSessionTimelineStory
title="Moved Session location"
description="A changed working directory stays compact, truncates, and exposes its tooltip."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_location",
type: "location-switched",
location: { directory: `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` },
time: { created: 1_735_689_633_000 },
},
],
}}
width="480px"
/>
),
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
@@ -0,0 +1,380 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { SessionTimeline } from "./session-timeline"
export default {
title: "OpenCode/Conversation/Tool projection",
id: "current-session-tool-projection",
component: SessionTimeline,
parameters: {
layout: "fullscreen",
docs: {
description: {
component: "Inspectable tool families, error outcomes, grouping, labels, and reactive lifecycle transitions.",
},
},
},
}
function tool(
id: string,
name: string,
status: "streaming" | "running" | "completed" | "error",
input: Record<string, JsonValue>,
options: { metadata?: Record<string, JsonValue>; output?: string; error?: string } = {},
): SessionMessageAssistantTool {
const state =
status === "streaming"
? { status, input: JSON.stringify(input) }
: status === "running"
? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } }
: status === "error"
? {
status,
input,
error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` },
metadata: options.metadata,
}
: {
status,
input,
content: [{ type: "text" as const, text: options.output ?? "Complete" }],
metadata: options.metadata,
}
return {
type: "tool",
id,
name,
state,
time: {
created: STORY_TIME,
...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }),
...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}),
},
}
}
function document(content: SessionMessageAssistant["content"], busy = false): SessionDocument {
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_tool_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) },
},
],
status: { type: busy ? "busy" : "idle" },
diffs: [],
}
}
function patchFile(file: string, status: "modified" | "added" = "modified") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: 1,
deletions: status === "added" ? 0 : 1,
}
}
const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
export const EveryToolFamily = {
render: () => (
<CurrentSessionTimelineStory
title="Every admitted tool family"
description="Context operations group together, visible tools retain their production cards, and hidden todos stay absent."
document={document([
tool("tool_family_read", "read", "completed", { path: "src/a.ts" }),
tool("tool_family_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
tool("tool_family_grep", "grep", "completed", { path: ".", pattern: "value" }),
tool("tool_family_list", "list", "completed", { path: "src" }),
tool("tool_family_webfetch", "webfetch", "completed", { url: "https://example.com" }),
tool("tool_family_websearch", "websearch", "completed", { query: "timeline stability" }),
tool("tool_family_subagent", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
tool("tool_family_shell", "shell", "completed", { command: "printf stable" }, { output: "stable" }),
tool(
"tool_family_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: "before", newString: "after" },
{ metadata: { files: [patchFile("src/a.ts")] } },
),
tool("tool_family_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true" }),
tool(
"tool_family_patch",
"patch",
"completed",
{ patchText: "Update src/b.ts" },
{ metadata: { files: [patchFile("src/b.ts")] } },
),
tool("tool_family_todo", "todowrite", "completed", { todos: [] }),
tool("tool_family_question", "question", "completed", questions, { metadata: { answers: [["Yes"]] } }),
tool("tool_family_skill", "skill", "completed", { name: "stability" }),
tool("tool_family_custom", "custom_mcp_tool", "completed", { target: "timeline" }),
])}
width="860px"
/>
),
}
export const EveryToolError = {
render: () => {
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const input = (name: string): Record<string, JsonValue> => {
if (name === "shell") return { command: "exit 1" }
if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" }
if (name === "patch") return { patchText: "Update src/error.ts" }
if (name === "webfetch") return { url: "https://example.com" }
if (name === "websearch") return { query: "failure" }
if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." }
if (name === "skill") return { name: "failure" }
return { target: "failure" }
}
return (
<CurrentSessionTimelineStory
title="Every visible tool error"
description="Ordinary tool failures remain visible, dismissed questions keep their explanation, and todo failures stay hidden."
document={document([
...names.map((name) => tool(`tool_error_${name}`, name, "error", input(name))),
tool("tool_error_question_dismissed", "question", "error", questions, {
error: "The user dismissed this question",
}),
tool("tool_error_question_transport", "question", "error", questions, { error: "Question transport failed" }),
tool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
])}
width="860px"
/>
)
},
}
export const SearchProviders = {
render: () => (
<CurrentSessionTimelineStory
title="Web search providers"
description="Provider-specific search labels remain distinct from the generic fallback."
document={document([
tool(
"tool_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
tool("tool_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
tool("tool_search_generic", "websearch", "completed", { query: "generic" }),
])}
/>
),
}
export const ContextLabels = {
render: () => (
<CurrentSessionTimelineStory
title="Context result labels"
description="Grouped context calls expose singular and plural match counts and the read filename."
document={document([
tool("tool_label_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
tool("tool_label_grep", "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
tool("tool_label_read", "read", "completed", { path: "src/a.ts" }),
])}
/>
),
}
export const SkillLabels = {
render: () => (
<CurrentSessionTimelineStory
title="Loaded skill labels"
description="Running skills use their identifier while completed skills prefer result metadata."
document={document([
tool("tool_skill_id", "skill", "running", { id: "frontend-design" }),
tool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
])}
/>
),
}
export const ContextBoundaries = {
render: () => (
<CurrentSessionTimelineStory
title="Separated context groups"
description="Text and shell tools separate singleton and adjacent context operations."
document={document([
tool("tool_boundary_read", "read", "completed", { path: "src/a.ts" }),
{ type: "text", text: "Boundary text" },
tool("tool_boundary_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
tool("tool_boundary_grep", "grep", "completed", { path: ".", pattern: "stable" }),
tool("tool_boundary_shell", "shell", "completed", { command: "printf done" }, { output: "done" }),
tool("tool_boundary_list", "list", "completed", { path: "src" }),
])}
/>
),
}
export const GroupedEdits = {
render: () => (
<CurrentSessionTimelineStory
title="Repeated edits of one file"
description="Adjacent edits deduplicate the changed filename while preserving its expanded disclosure."
document={document([
tool(
"tool_grouped_edit_first",
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{ metadata: { files: [patchFile("src/first.ts")] } },
),
tool(
"tool_grouped_edit_second",
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{ metadata: { files: [patchFile("src/first.ts")] } },
),
])}
editToolDefaultOpen
/>
),
}
function ShellLifecycleStory(props: { expanded?: boolean; transition?: boolean }) {
const [state, setState] = createStore({ phase: props.transition ? "streaming" : "completed", revision: 0 })
const current = createMemo(() => {
const phase = state.phase as "streaming" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
tool("tool_shell_lifecycle", "shell", phase, command ? { command } : {}, {
output: phase === "running" ? "still running" : `line ${state.revision + 1}`,
}),
...(state.revision ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return document(content, phase !== "completed")
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Run command
</button>
<button type="button" onClick={() => setState("phase", "completed")}>
Complete command
</button>
<button type="button" onClick={() => setState("revision", (value) => value + 1)}>
Update output
</button>
</div>
<CurrentSessionProviders document={current()}>
<SessionTimeline document={current()} shellToolDefaultOpen={props.expanded} />
</CurrentSessionProviders>
</section>
)
}
export const StreamingShellLifecycle = { render: () => <ShellLifecycleStory transition /> }
export const CollapsedShellUpdates = { render: () => <ShellLifecycleStory /> }
export const ExpandedShellUpdates = { render: () => <ShellLifecycleStory expanded /> }
function ErrorTransitionStory() {
const [state, setState] = createStore({ failed: false })
const current = createMemo(() =>
document(
[
tool(
"tool_transition_shell",
"shell",
state.failed ? "error" : "running",
{ command: "exit 1" },
{
error: "Command exited 1",
},
),
tool("tool_transition_question", "question", state.failed ? "error" : "running", questions, {
error: "The user dismissed this question",
}),
],
!state.failed,
),
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("failed", true)}>
Fail running tools
</button>
<CurrentSessionProviders document={current()}>
<SessionTimeline document={current()} />
</CurrentSessionProviders>
</section>
)
}
export const RunningToolErrors = { render: () => <ErrorTransitionStory /> }
function GroupedPatchStory(props: { failure?: boolean }) {
const [state, setState] = createStore({ phase: "initial" })
const current = createMemo(() => {
const first = tool(
"tool_grouped_patch_first",
"patch",
state.phase === "failed" ? "error" : "completed",
{ patchText: "Update src/first.ts" },
{ metadata: { files: [patchFile("src/first.ts")] }, error: "Patch failed visibly" },
)
const include = props.failure || state.phase !== "initial"
const second = tool(
"tool_grouped_patch_second",
"patch",
state.phase === "complete" || state.phase === "failed" ? "completed" : "running",
{ patchText: "Update more files" },
{
metadata: {
files: props.failure
? [patchFile("src/surviving.ts")]
: state.phase === "complete"
? [patchFile("src/first.ts"), patchFile("src/second.ts", "added")]
: [],
},
},
)
return document(include ? [first, second] : [first], state.phase !== "complete")
})
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Append patch
</button>
<button type="button" onClick={() => setState("phase", "complete")}>
Complete patch
</button>
<button type="button" onClick={() => setState("phase", "failed")}>
Fail first patch
</button>
</div>
<CurrentSessionProviders document={current()}>
<SessionTimeline document={current()} />
</CurrentSessionProviders>
</section>
)
}
export const GroupedPatchUpdates = { render: () => <GroupedPatchStory /> }
export const GroupedPatchFailure = { render: () => <GroupedPatchStory failure /> }
+6 -1
View File
@@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => {
<MetaProvider>
<Font />
<ThemeProvider>
<LanguageProvider locale="en">
<LanguageProvider locale={typeof context.globals?.locale === "string" ? context.globals.locale : "en"}>
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
@@ -109,6 +109,11 @@ export default definePreview({
description: "Interface direction",
defaultValue: "ltr",
},
locale: {
name: "Locale",
description: "Interface language",
defaultValue: "en",
},
},
parameters: {
actions: {
+9 -1
View File
@@ -86,7 +86,15 @@ export function DevToolsBar() {
const offEscape = keymap.intercept(
"key",
({ event }) => {
if (!panel() || event.name !== "escape") return
if (!panel() || keymap.mode.current() !== "base") return
if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
if (renderer.getSelection()?.getSelectedText()) {
if ((config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) !== "select") return
renderer.clearSelection()
event.preventDefault()
event.stopPropagation()
return
}
event.preventDefault()
event.stopPropagation()
close()
@@ -759,6 +759,14 @@ export function Autocomplete(props: {
hide()
},
},
{
id: "prompt.clear",
title: "Dismiss autocomplete",
group: "Autocomplete",
run() {
hide(true)
},
},
{
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
+11 -1
View File
@@ -987,9 +987,19 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
priority: 1,
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
commands: [
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
{
bind: "ctrl+c",
title: "Exit shell mode",
group: "Prompt",
enabled: () => store.prompt.text === "",
run: () => setStore("mode", "normal"),
},
],
}
})
+12 -1
View File
@@ -38,6 +38,7 @@ import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -266,6 +267,11 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const dialog = useDialog()
onCleanup(Keymap.use().mode.push("menu"))
Keymap.createLayer(() => ({
mode: "menu",
commands: [{ bind: "escape,ctrl+c", title: "Close tab menu", group: "Tabs", run: props.onClose }],
}))
const actions = createMemo(() => {
const sessionID = props.state.sessionID
return [
@@ -1050,7 +1056,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (closeHold() && heldLayout()) {
const current = untrack(motion.value)
const seeded = changed
? seedSessionTabMotion(previous.split(":"), layout().tabs.map((tab) => tab.sessionID), current, next)
? seedSessionTabMotion(
previous.split(":"),
layout().tabs.map((tab) => tab.sessionID),
current,
next,
)
: current
if (!seeded) return motion.jump(next)
motion.jump({ ...seeded, widths: next.widths })
@@ -423,6 +423,12 @@ function DiffViewer(props: { context: Plugin.Context }) {
group: "VCS",
run: close,
},
{
id: "app.exit",
title: "Close diff viewer",
group: "VCS",
run: close,
},
{
id: "diff.down",
title: "Move diff viewer down",
+21 -13
View File
@@ -190,6 +190,9 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
messageID: item.id,
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
delivery: item.delivery,
...(item.payload.skills?.length
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
: {}),
}
}
@@ -388,6 +391,12 @@ function skillCommit(messageID: string, name: string, skillID = messageID): Stre
}
}
function skillCommits(messageID: string, skills: FooterQueuedPrompt["skills"] = []) {
return Array.from(new Map(skills.map((skill) => [skill.id, skill])).values(), (skill) =>
skillCommit(messageID, skill.name, skill.id),
)
}
function compactionCommit(messageID: string): StreamCommit {
return {
kind: "system",
@@ -667,7 +676,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
...skillCommits(message.id, message.skills),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
@@ -956,18 +965,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncPending()
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
if (!waiting && pending && !visible) {
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
])
}
write([], { phase: "running", status: "waiting for assistant" })
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
if (!waiting && pending && !visible)
commits.push({
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
})
write(commits, { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.inbox.delivery.changed") {
@@ -979,6 +986,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.messageIDs.has(event.data.inboxID)) return
state.messageIDs.add(event.data.inboxID)
write([
...skillCommits(event.data.inboxID, pending.skills),
{
kind: "user",
source: "system",
+1
View File
@@ -93,6 +93,7 @@ export type FooterQueuedPrompt = {
messageID: string
prompt: RunPrompt
delivery: RunDelivery
skills?: ReadonlyArray<{ id: string; name: string }>
}
export type QueuedPromptAction = "steer" | "cancel"
@@ -99,6 +99,7 @@ export function Composer(props: ComposerProps) {
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
{ bind: "ctrl+c", title: "Close composer", group: "Composer", run: close },
],
}))
@@ -126,10 +126,6 @@ export function SubagentsTab(props: { sessionID: string }) {
},
]
},
onClose: () => {
const parentID = session()?.parentID
if (parentID) navigate({ type: "session", sessionID: parentID })
},
})
onCleanup(cleanup)
})
+4
View File
@@ -540,6 +540,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
run() {
const text = textarea?.plainText ?? ""
if (!text) {
if (textual()) {
cancel()
return
}
setStore("editing", false)
return
}
+13 -1
View File
@@ -242,6 +242,11 @@ export function Session(props: { verticalTabsWidth: number }) {
if (sidebar() === "auto" && wide()) return true
return false
})
Keymap.createLayer(() => ({
priority: 10,
enabled: () => sidebarOpen() && !wide() && !disabled(),
commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: () => setSidebarOpen(false) }],
}))
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
@@ -1294,7 +1299,14 @@ export function Session(props: { verticalTabsWidth: number }) {
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
onClose={() => {
const parent = session()?.parentID
if (parent) {
navigate({ type: "session", sessionID: parent })
return
}
setComposer("open", false)
}}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
+14 -5
View File
@@ -299,7 +299,11 @@ function RejectPrompt(props: {
id: "app.exit",
title: "Cancel permission rejection",
group: "Permission",
run() {
run(_input, event) {
if (event?.ctrl && event.name === "c" && input.plainText) {
input.setText("")
return
}
props.onCancel()
},
},
@@ -436,6 +440,13 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
const shortcuts = Keymap.useShortcuts()
const id = () => props.id ?? "session.permission"
const group = () => props.group ?? "Permission"
const dismiss = () => {
if (store.expanded) {
setStore("expanded", false)
return
}
if (props.escapeKey) props.onSelect(props.escapeKey)
}
Keymap.createLayer(() => ({
mode: "base",
@@ -447,7 +458,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
title: "Reject permission",
group: group(),
bind: false as const,
run: () => props.onSelect(props.escapeKey!),
run: dismiss,
},
]
: []),
@@ -490,9 +501,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
group: group(),
run: () => props.onSelect(store.selected),
},
...(props.escapeKey
? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }]
: []),
...(props.escapeKey ? [{ bind: "escape", title: "Reject permission", group: group(), run: dismiss }] : []),
],
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
}))
+13 -3
View File
@@ -2,7 +2,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
@@ -112,7 +112,7 @@ function init() {
Keymap.createLayer(() => ({
mode: "modal",
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
enabled: store.stack.length > 0,
commands: [
{
bind: "escape",
@@ -121,6 +121,7 @@ function init() {
run: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
return
}
const current = store.stack.at(-1)
current?.onClose?.()
@@ -135,6 +136,13 @@ function init() {
run: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
return
}
const editor = renderer.currentFocusedEditor
if (editor?.plainText) {
if (editor instanceof InputRenderable) editor.value = ""
else editor.setText("")
return
}
const current = store.stack.at(-1)
current?.onClose?.()
@@ -225,7 +233,9 @@ export function DialogProvider(props: ParentProps) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
onMouseUp={
copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
+45
View File
@@ -636,3 +636,48 @@ test("configured app bindings execute settings and permission commands", async (
await server.stop()
}
})
test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
await setup.mockInput.typeText("/theme")
await setup.waitForFrame((frame) => frame.includes("Switch theme"))
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Switch theme"))
expect(setup.renderer.isDestroyed).toBe(false)
await setup.mockInput.typeText("!")
await setup.waitForFrame((frame) => frame.includes("Shell"))
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Shell"))
expect(setup.renderer.isDestroyed).toBe(false)
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
@@ -187,6 +187,17 @@ test("configured composer bindings work with a focused textarea", async () => {
}
})
test("ctrl+c closes the active composer", async () => {
const composer = await renderComposer("shell", {})
try {
composer.app.mockInput.pressKey("c", { ctrl: true })
await composer.app.waitFor(() => composer.closed() === 1)
} finally {
composer.app.renderer.destroy()
}
})
function session(id: string, title: string, parentID?: string) {
return {
id,
@@ -163,6 +163,48 @@ test("budgets option content for constrained and full-width large dialogs", () =
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
})
test("ctrl+c clears a dialog filter before closing the dialog", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
await select.app.mockInput.typeText("alpha")
await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "alpha")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "")
expect(select.app.captureCharFrame()).toContain("Mutable options")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitForFrame((frame) => !frame.includes("Mutable options"))
} finally {
select.app.renderer.destroy()
}
})
test("ctrl+c clears a dialog text selection before closing the dialog", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
const frame = select.app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Alpha"))
const column = frame[row]!.indexOf("Alpha") + 1
await select.app.mockMouse.click(column, row)
await select.app.mockMouse.click(column, row)
expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitFor(() => !select.app.renderer.getSelection())
expect(select.app.captureCharFrame()).toContain("Mutable options")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitForFrame((frame) => !frame.includes("Mutable options"))
} finally {
select.app.renderer.destroy()
}
})
test("renders the complete truncated footer within the option row", async () => {
await using tmp = await tmpdir()
const title = "Project"
@@ -49,6 +49,18 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
try {
viewer.app.mockInput.pressKey("c", { ctrl: true })
await viewer.app.waitFor(() => viewer.current().type !== "plugin")
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
})
test("shows an error instead of an empty diff when loading fails", async () => {
const viewer = await renderDiffViewer([], { fail: true })
try {
+19
View File
@@ -606,6 +606,25 @@ test("text fields retain default paste behavior", async () => {
}
})
test("ctrl+c clears a text field before cancelling its form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
try {
await prompt.app.mockInput.typeText("draft answer")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "draft answer")
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "")
expect(prompt.cancellations).toEqual([])
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
} finally {
prompt.app.renderer.destroy()
}
})
test("pasting on a choice without custom answers does not open an editor", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [
@@ -108,7 +108,9 @@ test("the tab context menu keeps preview tabs open without offering promotion fo
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame()).not.toContain("Keep open")
await app.mockMouse.click(5, 0)
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
const frame = app.captureCharFrame().split("\n")
@@ -667,7 +667,13 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: { text: "follow up" },
payload: {
text: "follow up",
skills: [
{ id: "effect", name: "Effect", text: "Use Effect services" },
{ id: "effect", name: "Effect" },
],
},
delivery: "queue",
},
{
@@ -706,9 +712,10 @@ describe("V2 mini transport", () => {
})
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toEqual([
expect.objectContaining({ kind: "system", partID: "skill:effect", text: '→ Skill "Effect"' }),
expect.objectContaining({ kind: "user", text: "follow up" }),
])
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({
id: "evt_queued",
@@ -739,7 +746,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1", inboxID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
+32 -8
View File
@@ -27,7 +27,7 @@ export interface EntryPoint {
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
@@ -124,14 +124,16 @@ const layer = Layer.effect(
}
return pkg
})
const reify = (input: { dir: string; add?: string[] }) =>
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* NpmConfig.load(input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const arborist = new Arborist({
...npmOptions,
...options,
path: input.dir,
binLinks: true,
progress: false,
@@ -141,8 +143,9 @@ const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
...npmOptions,
...options,
add,
update: input.update,
save: true,
saveType: "prod",
}),
@@ -159,19 +162,33 @@ const layer = Layer.effect(
}),
)
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
const parsed = (() => {
try {
return npa(pkg).name ?? undefined
return npa(pkg)
} catch {
return undefined
}
})()
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name))
const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
if (refresh) {
refreshed.add(pkg)
if (cached)
yield* reify({ dir, add: [pkg], update: true }).pipe(
Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")),
)
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
@@ -283,3 +300,10 @@ export async function resolve(...args: Parameters<Interface["resolve"]>) {
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
if (parsed.type !== "git") return false
return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "")
}
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+3 -2
View File
@@ -93,8 +93,9 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package
version or an unwatched dependency.
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts