mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): add commentary timeline category (#55248)
This commit is contained in:
committed by
GitHub
Unverified
parent
3fe48b7d81
commit
8737af203d
@@ -8,7 +8,7 @@ story("maps grouped and collapsed switches to the timeline settings", async ({ m
|
||||
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
|
||||
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
|
||||
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.getByRole("switch")).toHaveCount(10)
|
||||
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
|
||||
await expect(grouped).toBeChecked()
|
||||
await expect(collapsed).toBeChecked()
|
||||
@@ -138,7 +138,8 @@ story("keeps visibility and switches in sync with the preset slider", async ({ m
|
||||
await slider.focus()
|
||||
await slider.press("Home")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
|
||||
await expect(component.getByRole("switch")).toHaveCount(0)
|
||||
// Messages only hides every activity but keeps commentary, which is text.
|
||||
await expect(component.getByRole("switch")).toHaveAccessibleName("Commentary grouped")
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
|
||||
await component.getByRole("button", { name: "Shell visibility" }).click()
|
||||
@@ -147,9 +148,9 @@ story("keeps visibility and switches in sync with the preset slider", async ({ m
|
||||
await slider.focus()
|
||||
await slider.press("End")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.getByRole("switch")).toHaveCount(10)
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(7)
|
||||
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -813,6 +813,7 @@ export const dict = {
|
||||
"settings.timeline.category.subagents": "Subagents",
|
||||
"settings.timeline.category.notices": "Notices",
|
||||
"settings.timeline.category.tools": "Other tools",
|
||||
"settings.timeline.category.commentary": "Commentary",
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
|
||||
@@ -158,6 +158,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
subagents: { ...detail.subagents },
|
||||
notices: { ...detail.notices },
|
||||
tools: { ...detail.tools },
|
||||
commentary: { ...detail.commentary },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ const generalSchema = Persistence.struct({
|
||||
subagents: placementOnlySchema,
|
||||
notices: placementOnlySchema,
|
||||
tools: placementOnlySchema,
|
||||
commentary: placementOnlySchema,
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
showTurnDiffs: Schema.Boolean,
|
||||
|
||||
@@ -743,6 +743,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Grouped commentary follows the list rhythm instead of the timeline's text spacing. */
|
||||
> [data-component="text-part"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> [data-component="tool-part-wrapper"]
|
||||
> [data-component="collapsible"]
|
||||
> [data-slot="collapsible-trigger"]:not([data-hide-details="true"]) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SessionMessageAssistant } from "@opencode/client/promise"
|
||||
import type { SessionDocument } from "../document"
|
||||
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME } from "../storybook/current-session-fixtures"
|
||||
import { storyTool } from "../storybook/current-session-scenarios"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { timelinePresets, type TimelinePlacement } from "./detail"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Conversation/Commentary",
|
||||
id: "current-session-commentary",
|
||||
component: SessionTimeline,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { commentary: "grouped" },
|
||||
argTypes: { commentary: { control: { type: "inline-radio" }, options: ["separate", "grouped", "hidden"] } },
|
||||
}
|
||||
|
||||
const step = (id: string, offset: number, content: SessionMessageAssistant["content"]) =>
|
||||
({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: STORY_MODEL,
|
||||
content,
|
||||
time: { created: STORY_TIME + offset, completed: STORY_TIME + offset + 900 },
|
||||
}) satisfies SessionDocument["messages"][number]
|
||||
|
||||
const document = {
|
||||
sessionID: CURRENT_SESSION_ID,
|
||||
status: { type: "idle" },
|
||||
diffs: [],
|
||||
messages: [
|
||||
{
|
||||
id: "msg_commentary_user",
|
||||
type: "user",
|
||||
text: "The timeline setting resets after a restart. Can you fix it?",
|
||||
metadata: { agent: "build", model: STORY_MODEL },
|
||||
time: { created: STORY_TIME },
|
||||
},
|
||||
step("msg_commentary_1", 100, [
|
||||
{ type: "text", text: "I'll find where the timeline setting is stored." },
|
||||
storyTool("commentary_grep", "grep", "completed", { pattern: "timelineDetail" }),
|
||||
storyTool("commentary_read", "read", "completed", { filePath: "src/settings/model.tsx" }),
|
||||
]),
|
||||
step("msg_commentary_2", 1100, [
|
||||
{ type: "text", text: "The stored value is missing the new category, so decoding falls back. Running the tests." },
|
||||
storyTool("commentary_shell", "shell", "completed", { command: "bun test settings" }, { output: "12 pass" }),
|
||||
]),
|
||||
step("msg_commentary_3", 2100, [
|
||||
{ type: "text", text: "Tests pass. Writing up the fix.", state: { phase: "commentary" } },
|
||||
{
|
||||
type: "text",
|
||||
text: "Fixed. Stored settings that lack a category now take that category from the default preset, so the value survives a restart.",
|
||||
},
|
||||
]),
|
||||
],
|
||||
} satisfies SessionDocument
|
||||
|
||||
export const Placement = {
|
||||
render: (args: { commentary: TimelinePlacement }) => (
|
||||
<section class="mx-auto min-h-screen w-full max-w-[720px] bg-v2-background-bg-base p-6">
|
||||
<CurrentSessionProviders document={document}>
|
||||
<SessionTimeline
|
||||
document={document}
|
||||
timelineDetail={{ ...timelinePresets[2].value, commentary: { placement: args.commentary } }}
|
||||
/>
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { shellResultFailed } from "../message/current-tool-state"
|
||||
|
||||
export const timelineCategories = ["shell", "edit", "thinking", "subagents", "notices", "tools"] as const
|
||||
export const timelineCategories = ["shell", "edit", "thinking", "subagents", "notices", "tools", "commentary"] as const
|
||||
|
||||
export type TimelineCategory = (typeof timelineCategories)[number]
|
||||
|
||||
@@ -16,6 +16,7 @@ export type TimelineDetail = {
|
||||
subagents: { placement: TimelinePlacement }
|
||||
notices: { placement: TimelinePlacement }
|
||||
tools: { placement: TimelinePlacement }
|
||||
commentary: { placement: TimelinePlacement }
|
||||
}
|
||||
|
||||
export const timelinePresets = [
|
||||
@@ -28,6 +29,7 @@ export const timelinePresets = [
|
||||
subagents: { placement: "separate" },
|
||||
notices: { placement: "separate" },
|
||||
tools: { placement: "separate" },
|
||||
commentary: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -39,6 +41,7 @@ export const timelinePresets = [
|
||||
subagents: { placement: "separate" },
|
||||
notices: { placement: "grouped" },
|
||||
tools: { placement: "grouped" },
|
||||
commentary: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -50,6 +53,7 @@ export const timelinePresets = [
|
||||
subagents: { placement: "grouped" },
|
||||
notices: { placement: "grouped" },
|
||||
tools: { placement: "grouped" },
|
||||
commentary: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -61,6 +65,7 @@ export const timelinePresets = [
|
||||
subagents: { placement: "grouped" },
|
||||
notices: { placement: "hidden" },
|
||||
tools: { placement: "hidden" },
|
||||
commentary: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -72,6 +77,7 @@ export const timelinePresets = [
|
||||
subagents: { placement: "hidden" },
|
||||
notices: { placement: "hidden" },
|
||||
tools: { placement: "hidden" },
|
||||
commentary: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly { id: string; value: TimelineDetail }[]
|
||||
|
||||
@@ -356,6 +356,20 @@ export namespace Timeline {
|
||||
turns.findLast((turn) => !pendingInputIDs?.has(turn.id) || turn.entries.some((entry) => entry.type === "idle"))
|
||||
?.id ?? turns.at(-1)?.id
|
||||
|
||||
const commentary = new Set(
|
||||
detail && detail.commentary.placement !== "separate"
|
||||
? turns.flatMap((turn) =>
|
||||
turnCommentary(turn.entries, turn.id !== activeMessageID || status.type === "idle", (content) =>
|
||||
isRenderable(content, showReasoning, detail),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
)
|
||||
|
||||
const visible = (content: Content, showReasoning: boolean, detail?: TimelineDetail) =>
|
||||
!(detail?.commentary.placement === "hidden" && commentary.has(content)) &&
|
||||
isRenderable(content, showReasoning, detail)
|
||||
|
||||
const visibleNotice = (message: Notice) =>
|
||||
!detail || detail.notices.placement !== "hidden" || timelineNoticeRequired(message)
|
||||
|
||||
@@ -372,7 +386,7 @@ export namespace Timeline {
|
||||
entry.type === "diff" ||
|
||||
!!entry.message.error ||
|
||||
!!entry.message.retry ||
|
||||
entry.message.content.some((content) => isRenderable(content, showReasoning, detail)),
|
||||
entry.message.content.some((content) => visible(content, showReasoning, detail)),
|
||||
)
|
||||
})
|
||||
: turns
|
||||
@@ -407,8 +421,9 @@ export namespace Timeline {
|
||||
turn.id === activeMessageID,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
isRenderable,
|
||||
visible,
|
||||
detail,
|
||||
commentary,
|
||||
)
|
||||
}),
|
||||
]
|
||||
@@ -465,6 +480,7 @@ export namespace Timeline {
|
||||
editToolDefaultOpen = false,
|
||||
isRenderable = renderable,
|
||||
detail?: TimelineDetail,
|
||||
commentary: ReadonlySet<Content> = new Set(),
|
||||
) {
|
||||
const rows: TimelineRow.TimelineRow[] = []
|
||||
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
|
||||
@@ -524,7 +540,7 @@ export namespace Timeline {
|
||||
|
||||
const appendGroups = (items: typeof refs) => {
|
||||
let offset = 0
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen, detail).forEach((group) => {
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen, commentary, detail).forEach((group) => {
|
||||
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
|
||||
offset += group.type === "part" ? 1 : group.refs.length
|
||||
rows.push(
|
||||
@@ -648,6 +664,26 @@ function shellFailed(message: SessionMessageShell) {
|
||||
)
|
||||
}
|
||||
|
||||
// Text that a tool call follows in the same turn is commentary; text after the last tool call is the answer.
|
||||
// OpenAI Responses models label each message with a phase, which wins over position. A settled turn
|
||||
// without an answer keeps its last text, so an interrupted turn still ends with something to read.
|
||||
function turnCommentary(entries: Entry[], settled: boolean, visible: (content: Content) => boolean) {
|
||||
const contents = entries.flatMap((entry) => (entry.type === "assistant" ? entry.message.content : []))
|
||||
const lastTool = contents.findLastIndex((content) => content.type === "tool")
|
||||
|
||||
const commentary = contents.filter(
|
||||
(content, index) =>
|
||||
content.type === "text" &&
|
||||
(content.state?.phase === "commentary" || (content.state?.phase !== "final_answer" && index < lastTool)),
|
||||
)
|
||||
|
||||
const texts = contents.filter((content) => content.type === "text" && visible(content))
|
||||
|
||||
if (!settled || texts.some((content) => !commentary.includes(content))) return commentary
|
||||
|
||||
return commentary.filter((content) => content !== texts.at(-1))
|
||||
}
|
||||
|
||||
function groupMessages(rows: TimelineRow.TimelineRow[], detail: TimelineDetail, separate: ReadonlySet<string>) {
|
||||
return rows.reduce<TimelineRow.TimelineRow[]>((result, row) => {
|
||||
const previous = result.at(-1)
|
||||
@@ -853,6 +889,7 @@ function groupContent(
|
||||
items: { messageID: string; partID: string; content: Content }[],
|
||||
shellToolDefaultOpen: boolean,
|
||||
editToolDefaultOpen: boolean,
|
||||
commentary: ReadonlySet<Content>,
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
@@ -890,6 +927,7 @@ function groupContent(
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
adjacent?.type === "context" && adjacent.tools,
|
||||
commentary,
|
||||
detail,
|
||||
)
|
||||
|
||||
@@ -919,11 +957,12 @@ function contentGroupType(
|
||||
shellExpanded: boolean,
|
||||
editExpanded: boolean,
|
||||
hasContextGroup: boolean,
|
||||
commentary: ReadonlySet<Content>,
|
||||
detail?: TimelineDetail,
|
||||
) {
|
||||
if (content.type === "tool") return toolGroupType(content, shellExpanded, editExpanded, hasContextGroup, detail)
|
||||
|
||||
if (content.type !== "reasoning") return undefined
|
||||
if (content.type === "text") return commentary.has(content) ? "context" : undefined
|
||||
|
||||
return detail && detail.thinking.placement !== "grouped" ? undefined : "context"
|
||||
}
|
||||
|
||||
@@ -554,6 +554,54 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test.each([
|
||||
["grouped", "assistant-part:context:context:msg_a1:msg_a1:text:0"],
|
||||
["hidden", "assistant-part:context:context:msg_a1:tool_read"],
|
||||
] as const)("%s commentary keeps the answer of each turn", (placement, group) => {
|
||||
const step = (id: string, content: SessionMessageAssistant["content"]) =>
|
||||
({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content,
|
||||
time: { created: 2, completed: 3 },
|
||||
}) satisfies SessionMessageInfo
|
||||
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "fix", time: { created: 1 } },
|
||||
step("msg_a1", [{ type: "text", text: "Checking." }, storyTool("tool_read", "read", "completed", {})]),
|
||||
step("msg_a2", [
|
||||
{ type: "text", text: "Checks pass.", state: { phase: "commentary" } },
|
||||
{ type: "text", text: "Fixed." },
|
||||
]),
|
||||
{ id: "msg_stop", type: "user", text: "stop", time: { created: 4 } },
|
||||
step("msg_a3", [{ type: "text", text: "Looking." }, storyTool("tool_grep", "grep", "completed", {})]),
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const rows = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
false,
|
||||
{ type: "idle" },
|
||||
undefined,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
{ ...timelinePresets[2].value, commentary: { placement } },
|
||||
).rows
|
||||
|
||||
// The interrupted second turn has no answer, so its last commentary stays visible.
|
||||
expect(rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
group,
|
||||
"assistant-part:part:part:msg_a2:msg_a2:text:1",
|
||||
"turn-gap:msg_stop",
|
||||
"user-message:msg_stop",
|
||||
"assistant-part:part:part:msg_a3:msg_a3:text:0",
|
||||
"assistant-part:context:context:msg_a3:tool_grep",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps assistant errors and retries before later notices", () => {
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[
|
||||
|
||||
@@ -205,6 +205,15 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
]
|
||||
const message = input.projection.messageByID().get(ref.messageID)
|
||||
|
||||
if (content?.type === "text" && message?.type === "assistant")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
id: ref.partID,
|
||||
render: () => <SessionAssistantContent message={message} content={content} contentID={ref.partID} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (ref.messageID !== ref.partID || !message) return []
|
||||
|
||||
if (message.type === "shell")
|
||||
|
||||
@@ -454,7 +454,7 @@ function ExaOutput(props: { output?: string }) {
|
||||
export type ContextGroupPart =
|
||||
| SessionMessageAssistantTool
|
||||
| (SessionMessageAssistantReasoning & { id: string; streaming?: boolean })
|
||||
| { type: "notice" | "shell"; id: string; render: () => JSX.Element }
|
||||
| { type: "notice" | "shell" | "text"; id: string; render: () => JSX.Element }
|
||||
|
||||
export function CurrentContextToolGroup(props: {
|
||||
parts: ContextGroupPart[]
|
||||
@@ -650,7 +650,10 @@ export function CurrentContextToolGroup(props: {
|
||||
const callback = createMemo(() => {
|
||||
const value = item()
|
||||
|
||||
return !Array.isArray(value) && (value.type === "notice" || value.type === "shell") ? value : undefined
|
||||
return !Array.isArray(value) &&
|
||||
(value.type === "notice" || value.type === "shell" || value.type === "text")
|
||||
? value
|
||||
: undefined
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user