mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 10:06:09 +00:00
Compare commits
4
Commits
beta
...
stream-markdown
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca3e5dac5 | ||
|
|
0a5349c51c | ||
|
|
3fd93b659b | ||
|
|
17a445de3e |
@@ -593,8 +593,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.6",
|
||||
"@opentui/solid": ">=0.5.6",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -717,7 +717,6 @@
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
@@ -727,6 +726,7 @@
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -1088,9 +1088,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2216,27 +2216,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6" }, "peerDependencies": { "@opentui/react": "0.5.6", "@opentui/solid": "0.5.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1lR1aFRSgHCwN31YOHDQQVSqz1YNN7DsoIvWHjEiQJ12kTxHg9G7Q1HHFJttM838RWsNOvW6gBAhOr403VL8rg=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kxmrQS31sgziW/qZDuFYuiT3CvcUQg9ZQA1qKCEb5mBoInur4fnECOdTaM2S8c3cidcV0z2yu41NP1Dypkl+BQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2+fEzSA/1LPv/Hw7TP1Gu8boByTqoKyAvS1TKX2vLpo=",
|
||||
"aarch64-linux": "sha256-+xOGlHZjkaddY91AOxq7xyUDFFKFWwaYGJOZQkV/Rtk=",
|
||||
"aarch64-darwin": "sha256-HS9mG3OBsCBw2CjIQ4OE6fHOZ07gt4B1bS3YJrXG7Mw=",
|
||||
"x86_64-darwin": "sha256-+gZT6aNbQRWPI7+hEhNOoE+/6uuIEUcEOVndtuFs7Fs="
|
||||
"x86_64-linux": "sha256-tdNjQEuCMADQJpeAK0BajPyQkt8X2pS9foyQYxj+vgg=",
|
||||
"aarch64-linux": "sha256-RXvYaZXpgTyZzgPu74fat1bbT9zJUf+S0kpm9rXtr6k=",
|
||||
"aarch64-darwin": "sha256-MDinXCNrEn5pH75nmK/dsl78hcS4gu913Sf2fxj40r0=",
|
||||
"x86_64-darwin": "sha256-pn9t+1sNDjQh25nfPozD11aIJJsHgxhU9uB4tR0UnJk="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -259,6 +259,22 @@ export function event(
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
|
||||
return makeEvent("session.compaction.started", data)
|
||||
}
|
||||
|
||||
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
|
||||
return makeEvent("session.compaction.delta", data)
|
||||
}
|
||||
|
||||
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
|
||||
return makeEvent("session.compaction.ended", data)
|
||||
}
|
||||
|
||||
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
|
||||
return makeEvent("session.compaction.failed", data)
|
||||
}
|
||||
|
||||
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
|
||||
return makeEvent("session.tool.input.started", data)
|
||||
}
|
||||
|
||||
@@ -45,21 +45,17 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await lineNumber.hover()
|
||||
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||
await expect(comment).toHaveCount(1)
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.focus()
|
||||
await expect(comment).toBeFocused()
|
||||
}).toPass({ timeout: 10_000 })
|
||||
await comment.press("Enter")
|
||||
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")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
@@ -73,13 +82,93 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
sessionID,
|
||||
text: "## Checkpoint\n\nStreamed implementation details.",
|
||||
}),
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Checkpoint\n\nFinal implementation details.",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [toolPart("call_subagent", "subagent", "streaming", {})],
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -87,10 +176,7 @@ test("shows a delegating row while subagent input streams", async ({ page }) =>
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Delegating agent...",
|
||||
)
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
|
||||
@@ -99,8 +99,8 @@ test("labels completed searches with result counts", async ({ page }) => {
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
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.nth(0)).toContainText("(1 match)")
|
||||
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
@@ -112,8 +112,10 @@ test("labels read tools from their path input", async ({ page }) => {
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'),
|
||||
).toHaveText("a.ts")
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.6",
|
||||
"@opentui/solid": ">=0.5.6",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"test": "bun test src --only-failures"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -75,7 +76,6 @@
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator"
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import { parseMarkdownNodes } from "./markdown-solid"
|
||||
|
||||
beforeAll(() => GlobalRegistrator.register())
|
||||
afterAll(() => GlobalRegistrator.unregister())
|
||||
|
||||
test("assigns stable paths to elements and individual words", () => {
|
||||
expect(parseMarkdownNodes('<p class="lead">Hello <strong>streaming</strong> world</p>', true)).toEqual([
|
||||
{
|
||||
key: "0",
|
||||
type: "element",
|
||||
tag: "p",
|
||||
attributes: { class: "lead" },
|
||||
children: [
|
||||
{ key: "0.0:0", type: "word", text: "Hello" },
|
||||
{ key: "0.0:1", type: "text", text: " " },
|
||||
{
|
||||
key: "0.1",
|
||||
type: "element",
|
||||
tag: "strong",
|
||||
attributes: {},
|
||||
children: [{ key: "0.1.0:0", type: "word", text: "streaming" }],
|
||||
},
|
||||
{ key: "0.2:1", type: "text", text: " " },
|
||||
{ key: "0.2:2", type: "word", text: "world" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps completed block text compact", () => {
|
||||
expect(parseMarkdownNodes("<p>Hello world</p>", false)).toEqual([
|
||||
{
|
||||
key: "0",
|
||||
type: "element",
|
||||
tag: "p",
|
||||
attributes: {},
|
||||
children: [{ key: "0.0", type: "text", text: "Hello world" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { For, onMount } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Dynamic, render } from "solid-js/web"
|
||||
|
||||
type MarkdownNode =
|
||||
| { key: string; type: "element"; tag: string; attributes: Record<string, string>; children: MarkdownNode[] }
|
||||
| { key: string; type: "text"; text: string }
|
||||
| { key: string; type: "word"; text: string }
|
||||
|
||||
export function createMarkdownRenderer(root: HTMLDivElement, html: string, words: boolean) {
|
||||
const [nodes, setNodes] = createStore(parseMarkdownNodes(html, words))
|
||||
let ready = false
|
||||
const dispose = render(
|
||||
() => <For each={nodes}>{(node) => <MarkdownDomNode node={node} animate={() => ready} />}</For>,
|
||||
root,
|
||||
)
|
||||
ready = true
|
||||
|
||||
return {
|
||||
update(next: string, nextWords: boolean) {
|
||||
setNodes(reconcile(parseMarkdownNodes(next, nextWords), { key: "key" }))
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean }) {
|
||||
const node = props.node
|
||||
if (node.type === "text") return node.text
|
||||
if (node.type === "word") {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
onMount(() => {
|
||||
if (props.animate()) ref?.setAttribute("data-markdown-enter", "")
|
||||
})
|
||||
return (
|
||||
<span ref={ref} data-markdown-word="">
|
||||
{node.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Dynamic component={node.tag} {...node.attributes}>
|
||||
<For each={node.children}>{(node) => <MarkdownDomNode node={node} animate={props.animate} />}</For>
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
export function parseMarkdownNodes(html: string, words: boolean) {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = html
|
||||
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words))
|
||||
}
|
||||
|
||||
function parseNode(node: Node, key: string, words: boolean): MarkdownNode[] {
|
||||
if (node instanceof Text) {
|
||||
if (!words) return [{ key, type: "text", text: node.data }]
|
||||
return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => {
|
||||
if (!text) return []
|
||||
if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }]
|
||||
return [{ key: `${key}:${index}`, type: "word", text }]
|
||||
})
|
||||
}
|
||||
if (!(node instanceof Element)) return []
|
||||
return [
|
||||
{
|
||||
key,
|
||||
type: "element",
|
||||
tag: node.tagName.toLowerCase(),
|
||||
attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])),
|
||||
children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words)),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,14 @@
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
|
||||
[data-markdown-word] {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
[data-markdown-word][data-markdown-enter] {
|
||||
animation: markdown-word-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
/* Spacing for flow */
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
@@ -329,6 +337,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes markdown-word-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="markdown"] [data-markdown-word][data-markdown-enter] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="markdown"] > * {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { markdown } from "@opencode-ai/ui/storybook/fixtures"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { Markdown } from "./markdown"
|
||||
|
||||
export default {
|
||||
@@ -32,3 +33,22 @@ export const CompactResult = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const streamed =
|
||||
"Streaming Markdown now keeps existing elements alive while each newly arriving word fades into the response."
|
||||
|
||||
function StreamingMarkdown() {
|
||||
const words = streamed.match(/\S+\s*/g) ?? []
|
||||
const [count, setCount] = createSignal(1)
|
||||
const timer = setInterval(() => setCount((value) => Math.min(value + 1, words.length)), 180)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
return <Markdown text={words.slice(0, count()).join("")} streaming={count() < words.length} />
|
||||
}
|
||||
|
||||
export const StreamingResponse = {
|
||||
render: () => (
|
||||
<div class="mx-auto max-w-[760px] rounded-lg border border-border-weak-base bg-background-base px-5 py-4">
|
||||
<StreamingMarkdown />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import {
|
||||
type ComponentProps,
|
||||
@@ -32,6 +31,7 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
import { renderMermaidSvg } from "./markdown-mermaid"
|
||||
import { createMarkdownRenderer } from "./markdown-solid"
|
||||
|
||||
type RenderedBlock =
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
@@ -54,6 +54,7 @@ type RenderResult = {
|
||||
}
|
||||
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
const renderedMarkdown = new WeakMap<HTMLDivElement, ReturnType<typeof createMarkdownRenderer>>()
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
@@ -177,6 +178,17 @@ function disposeCopyButtons(root: Element) {
|
||||
hosts.forEach(disposeCopyButton)
|
||||
}
|
||||
|
||||
function disposeRenderedMarkdown(root: Element) {
|
||||
const blocks = [
|
||||
...(root instanceof HTMLDivElement && root.hasAttribute("data-markdown-block") ? [root] : []),
|
||||
...Array.from(root.querySelectorAll<HTMLDivElement>("[data-markdown-block]")),
|
||||
]
|
||||
blocks.forEach((block) => {
|
||||
renderedMarkdown.get(block)?.dispose()
|
||||
renderedMarkdown.delete(block)
|
||||
})
|
||||
}
|
||||
|
||||
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
||||
|
||||
function codeKind(language: string | undefined) {
|
||||
@@ -185,12 +197,6 @@ function codeKind(language: string | undefined) {
|
||||
if (shellLanguages.has(value)) return "shell"
|
||||
}
|
||||
|
||||
function codeLanguage(block: HTMLPreElement) {
|
||||
const code = block.querySelector("code")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
|
||||
}
|
||||
|
||||
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
||||
if (language) wrapper.dataset.language = language
|
||||
else delete wrapper.dataset.language
|
||||
@@ -200,37 +206,6 @@ function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
||||
else delete wrapper.dataset.codeKind
|
||||
}
|
||||
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
||||
if (!wrapped) {
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
applyCodeMetadata(wrapper, codeLanguage(block))
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
applyCodeMetadata(parent, codeLanguage(block))
|
||||
|
||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||
)
|
||||
|
||||
if (buttons.length === 0) {
|
||||
parent.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
for (const button of buttons.slice(1)) {
|
||||
disposeCopyButton(button)
|
||||
button.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) {
|
||||
if (!code.classList.contains("language-mermaid")) {
|
||||
clearMermaid(wrapper)
|
||||
@@ -311,18 +286,6 @@ function markInlineCode(root: HTMLDivElement) {
|
||||
}
|
||||
}
|
||||
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
const wrapper = block.parentElement
|
||||
const code = block.querySelector("code")
|
||||
if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete)
|
||||
}
|
||||
markInlineCode(root)
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
||||
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
|
||||
|
||||
@@ -552,6 +515,7 @@ export function Markdown(
|
||||
delete container.dataset.markdownReady
|
||||
if (content.length === 0) {
|
||||
disposeCopyButtons(container)
|
||||
disposeRenderedMarkdown(container)
|
||||
container.innerHTML = ""
|
||||
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
|
||||
return
|
||||
@@ -572,6 +536,7 @@ export function Markdown(
|
||||
const child = container.lastElementChild
|
||||
if (!child) break
|
||||
disposeCopyButtons(child)
|
||||
disposeRenderedMarkdown(child)
|
||||
child.remove()
|
||||
}
|
||||
container
|
||||
@@ -587,6 +552,8 @@ export function Markdown(
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
const container = root()
|
||||
if (container) disposeRenderedMarkdown(container)
|
||||
disposeMarkdownProjection(owner)
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
completedCode.clear()
|
||||
@@ -647,44 +614,39 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
||||
updateCodeBlock(container, current, block, labels)
|
||||
return
|
||||
}
|
||||
if (
|
||||
current instanceof HTMLDivElement &&
|
||||
current.dataset.markdownKey === block.key &&
|
||||
current.dataset.markdownHash === block.hash
|
||||
)
|
||||
return
|
||||
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
||||
if (existing?.dataset.markdownHash === block.hash) return
|
||||
|
||||
const next = document.createElement("div")
|
||||
const next = existing ?? document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.style.display = "contents"
|
||||
next.innerHTML = block.html
|
||||
decorate(next, labels, block.mode === "full")
|
||||
const source = document.createElement("div")
|
||||
source.innerHTML = block.html
|
||||
markInlineCode(source)
|
||||
markCodeLinks(source)
|
||||
const html = source.innerHTML
|
||||
|
||||
if (!(current instanceof HTMLDivElement)) {
|
||||
container.appendChild(next)
|
||||
if (existing) {
|
||||
const renderer = renderedMarkdown.get(existing)
|
||||
if (renderer) {
|
||||
renderer.update(html, block.mode === "live")
|
||||
return
|
||||
}
|
||||
existing.innerHTML = ""
|
||||
renderedMarkdown.set(existing, createMarkdownRenderer(existing, html, block.mode === "live"))
|
||||
return
|
||||
}
|
||||
|
||||
morphdom(current, next, {
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLElement &&
|
||||
toEl instanceof HTMLElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
onBeforeNodeDiscarded: (node) => {
|
||||
if (node instanceof Element) disposeCopyButtons(node)
|
||||
return true
|
||||
},
|
||||
})
|
||||
renderedMarkdown.set(next, createMarkdownRenderer(next, html, block.mode === "live"))
|
||||
if (!current) {
|
||||
container.appendChild(next)
|
||||
return
|
||||
}
|
||||
disposeCopyButtons(current)
|
||||
disposeRenderedMarkdown(current)
|
||||
current.replaceWith(next)
|
||||
}
|
||||
|
||||
function updateCodeBlock(
|
||||
@@ -756,6 +718,7 @@ function updateCodeBlock(
|
||||
})
|
||||
if (current) {
|
||||
disposeCopyButtons(current)
|
||||
disposeRenderedMarkdown(current)
|
||||
current.replaceWith(next)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import type {
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageCompaction,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
@@ -383,6 +385,37 @@ export function MessageDivider(props: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
|
||||
const i18n = useI18n()
|
||||
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
|
||||
const error = () => {
|
||||
if (props.message.status !== "failed" || props.message.error.type === "aborted") return ""
|
||||
return props.error
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-compaction-message">
|
||||
<MessageDivider label={i18n.t("ui.messagePart.compaction")} />
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantTextContent(props: {
|
||||
id: string
|
||||
text: string
|
||||
|
||||
@@ -917,7 +917,7 @@ export const retryDocument = document(
|
||||
{ type: "busy" },
|
||||
)
|
||||
|
||||
export const compactionDocument = document([
|
||||
const compactionPrelude = [
|
||||
user("msg_user_compact", "Continue the implementation after compacting context.", 60_000),
|
||||
assistant({
|
||||
id: "msg_assistant_before_compact",
|
||||
@@ -926,6 +926,26 @@ export const compactionDocument = document([
|
||||
content: [{ type: "text", text: "I inspected the timeline and identified the current message boundary." }],
|
||||
error: { type: "ExecutionInterrupted", message: "Context compaction started" },
|
||||
}),
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
export const compactionRunningDocument = document(
|
||||
[
|
||||
...compactionPrelude,
|
||||
{
|
||||
id: "msg_compaction_running",
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "auto",
|
||||
summary: "## Goal\n\nRestore compaction output while preserving timeline notices.",
|
||||
recent: "",
|
||||
time: { created: STORY_TIME + 63_000 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
{ type: "busy" },
|
||||
)
|
||||
|
||||
export const compactionDocument = document([
|
||||
...compactionPrelude,
|
||||
{
|
||||
id: "msg_compaction_complete",
|
||||
type: "compaction",
|
||||
@@ -943,6 +963,30 @@ export const compactionDocument = document([
|
||||
}),
|
||||
] satisfies SessionMessageInfo[])
|
||||
|
||||
export const compactionFailedDocument = document([
|
||||
...compactionPrelude,
|
||||
{
|
||||
id: "msg_compaction_failed",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "compaction.failed", message: "The provider rejected the compaction request." },
|
||||
time: { created: STORY_TIME + 63_000 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[])
|
||||
|
||||
export const compactionCancelledDocument = document([
|
||||
...compactionPrelude,
|
||||
{
|
||||
id: "msg_compaction_cancelled",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
time: { created: STORY_TIME + 63_000 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[])
|
||||
|
||||
export const subagentDocument = document(
|
||||
[
|
||||
user("msg_user_subagent", "Delegate the fixture review and report the result.", 70_000),
|
||||
|
||||
@@ -6,7 +6,10 @@ export {
|
||||
activeQuestionRequest,
|
||||
attachmentsAndCommentsDocument,
|
||||
attachmentsAndCommentsPresentation,
|
||||
compactionCancelledDocument,
|
||||
compactionDocument,
|
||||
compactionFailedDocument,
|
||||
compactionRunningDocument,
|
||||
editThenTestDocument,
|
||||
emptySessionDocument,
|
||||
fileChangeLoadingDocument,
|
||||
|
||||
@@ -533,7 +533,7 @@ function cleanHeading(value: string) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function unwrapErrorMessage(message: string) {
|
||||
export function unwrapErrorMessage(message: string) {
|
||||
const text = message.replace(/^Error:\s*/, "").trim()
|
||||
const parse = (value: string) => Option.getOrUndefined(decodeJson(value))
|
||||
const read = (value: string) => {
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
SessionUserMessage,
|
||||
currentContentDefaultOpen,
|
||||
} from "../message/current-message"
|
||||
import { SessionCompactionMessage } from "../message/message-content"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { createReactiveTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
|
||||
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
type Projection = ReturnType<typeof createReactiveTimelineProjection>
|
||||
@@ -203,7 +204,6 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
return { label: message.description ?? message.text }
|
||||
}
|
||||
if (message.type === "compaction") return { label: i18n.t("ui.messagePart.compaction"), data: message.status }
|
||||
if (message.type !== "synthetic") return undefined
|
||||
if (message.description === "Continuing after restart") return { label: message.description }
|
||||
const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined
|
||||
@@ -315,6 +315,15 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
return value
|
||||
}
|
||||
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
|
||||
const compaction = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "compaction" ? value : undefined
|
||||
})
|
||||
const compactionError = createMemo(() => {
|
||||
const value = compaction()
|
||||
if (value?.status !== "failed") return ""
|
||||
return unwrapErrorMessage(value.error.message)
|
||||
})
|
||||
const moved = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "location-switched" ? value : undefined
|
||||
@@ -325,6 +334,15 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
})
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<Show when={compaction()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<SessionCompactionMessage message={message()} error={compactionError()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={moved()}
|
||||
fallback={
|
||||
|
||||
@@ -3,7 +3,10 @@ import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
|
||||
import {
|
||||
attachmentsAndCommentsDocument,
|
||||
attachmentsAndCommentsPresentation,
|
||||
compactionCancelledDocument,
|
||||
compactionDocument,
|
||||
compactionFailedDocument,
|
||||
compactionRunningDocument,
|
||||
instructionsUpdatedMultipleDocument,
|
||||
instructionsUpdatedSingleDocument,
|
||||
requestHistoryDocument,
|
||||
@@ -61,6 +64,17 @@ export const ProviderRetry = {
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactionInProgress = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Compaction in progress"
|
||||
description="The production divider remains stable while the context summary streams below it."
|
||||
document={compactionRunningDocument}
|
||||
width="600px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactionAndContinuation = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
@@ -72,6 +86,28 @@ export const CompactionAndContinuation = {
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactionFailed = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Compaction failed"
|
||||
description="A failed compaction keeps the production divider and shows the provider error."
|
||||
document={compactionFailedDocument}
|
||||
width="600px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactionCancelled = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Compaction cancelled"
|
||||
description="An interrupted compaction keeps the production divider without an empty error block."
|
||||
document={compactionCancelledDocument}
|
||||
width="600px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const AnsweredQuestionAndDeclinedCommand = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
|
||||
Reference in New Issue
Block a user