mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60ebb5645 | ||
|
|
409bf6ea13 | ||
|
|
4b627a740d | ||
|
|
185c3e5136 | ||
|
|
cb863b8ea5 | ||
|
|
695c043e6b | ||
|
|
d53456da3b | ||
|
|
64e930628d | ||
|
|
3c73ce1dc7 |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -7,5 +7,5 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 ?? []),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
|
||||
@@ -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>"]),
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: "" },
|
||||
])
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -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 /> }
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] : [])],
|
||||
}))
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ?? "")
|
||||
}
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user