1
0
Эх сурвалжийг харах

feat(app): render current session timeline (#38466)

Brendan Allan 3 долоо хоног өмнө
parent
commit
ce9a875181

+ 27 - 6
packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts

@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
 const messages = [user, ...assistants]
 const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
 const lastID = userID
-const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
+const lastAssistant = assistants.at(-1)!
+const lastPart = lastAssistant.parts.at(-1)!
+const lastPartID =
+  lastPart.type === "tool"
+    ? lastPart.id
+    : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
 
 benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
   benchmark.setTimeout(180_000)
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
       return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
     },
   })
-  await page.route(`**/session/${fixture.targetID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
-  )
+  await page.route(`**/session/${fixture.targetID}`, (route) => {
+    const current = new URL(route.request().url()).pathname.startsWith("/api/")
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(
+        current
+          ? {
+              data: {
+                ...target,
+                cost: 0,
+                tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+                location: { directory: target.directory },
+              },
+            }
+          : target,
+      ),
+    })
+  })
   await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
   await page.goto(stressSessionHref(fixture.sourceID))
   await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
     parent: requests.filter((request) => request.type === "parent").length,
   }
   if (mode === "candidate") {
-    expect(requestCounts.parent).toBe(1)
-    expect(historyGates).toBe(1)
+    expect(requestCounts.parent).toBe(0)
+    expect(historyGates).toBe(0)
   }
   return { metrics, requestCounts, historyGateCount: historyGates }
 }

+ 13 - 6
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -283,6 +283,14 @@ export function MessageTimeline(props: {
     return sync().data.session_status[id] ?? idle
   })
   const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
+  const projectedMessages = createMemo(() => {
+    const id = sessionID()
+    if (!id) return []
+    const visible = new Set(props.userMessages.map((message) => message.id))
+    const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
+    const messages = sync().data.session_message[id] ?? []
+    return boundary ? messages.filter((message) => message.id < boundary) : messages
+  })
   const info = createMemo(() => {
     const id = sessionID()
     if (!id) return
@@ -324,7 +332,7 @@ export function MessageTimeline(props: {
   const showHeader = createMemo(() => !!(titleValue() || parentID()))
   const projection = createTimelineProjection({
     messages: sessionMessages,
-    userMessages: () => props.userMessages,
+    sessionMessages: projectedMessages,
     parts: getMsgParts,
     status: sessionStatus,
     showReasoningSummaries: settings.general.showReasoningSummaries,
@@ -664,8 +672,7 @@ export function MessageTimeline(props: {
   }))
 
   const titleMutation = useMutation(() => ({
-    mutationFn: (input: { id: string; title: string }) =>
-      sdk().client.session.update({ sessionID: input.id, title: input.title }),
+    mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
     onSuccess: (_, input) => {
       sync().set(
         produce((draft) => {
@@ -809,7 +816,7 @@ export function MessageTimeline(props: {
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
     await sdk()
-      .client.session.update({ sessionID, time: { archived: Date.now() } })
+      .api.session.archive({ sessionID })
       .then(() => {
         sync().set(
           produce((draft) => {
@@ -838,8 +845,8 @@ export function MessageTimeline(props: {
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
     const result = await sdk()
-      .client.session.delete({ sessionID })
-      .then((x) => x.data)
+      .api.session.remove({ sessionID })
+      .then(() => true)
       .catch((err) => {
         showToast({
           title: language.t("session.delete.failed.title"),

+ 13 - 43
packages/app/src/pages/session/timeline/projection.ts

@@ -1,16 +1,14 @@
-import { Binary } from "@opencode-ai/core/util/binary"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
-import { createMemo, mapArray, type Accessor } from "solid-js"
+import { createMemo, type Accessor } from "solid-js"
 import { reuseTimelineRows } from "./row-reconciliation"
 import { Timeline, TimelineRow } from "./rows"
 
 export { reuseTimelineRows } from "./row-reconciliation"
 
-const emptyAssistantMessages: AssistantMessage[] = []
-
 export function createTimelineProjection(input: {
   messages: Accessor<Message[]>
-  userMessages: Accessor<UserMessage[]>
+  sessionMessages: Accessor<SessionMessageInfo[]>
   parts: (messageID: string) => Part[]
   status: Accessor<SessionStatus>
   showReasoningSummaries: Accessor<boolean>
@@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
     })
     return result
   })
-  const activeMessageID = createMemo(() => {
-    const parentID = input
-      .messages()
-      .findLast(
-        (message): message is AssistantMessage =>
-          message.role === "assistant" && typeof message.time.completed !== "number",
-      )?.parentID
-    if (parentID) {
-      const messages = input.messages()
-      const result = Binary.search(messages, parentID, (message) => message.id)
-      const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
-      if (message?.role === "user") return message.id
-    }
-
-    if (input.status().type === "idle") return
-    return input.messages().findLast((message) => message.role === "user")?.id
-  })
-  const messageRowMemos = createMemo(
-    mapArray(input.userMessages, (userMessage, indexAccessor) =>
-      createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
-        reuseTimelineRows(
-          previous,
-          Timeline.constructMessageRows(
-            userMessage,
-            input.parts,
-            assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
-            indexAccessor(),
-            input.showReasoningSummaries(),
-            input.status().type,
-            activeMessageID() === userMessage.id,
-            input.inlineComments(),
-          ),
-        ),
-      ),
+  const projection = createMemo(() =>
+    Timeline.constructSessionMessageRows(
+      input.sessionMessages(),
+      (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
+      input.parts,
+      input.showReasoningSummaries(),
+      input.status().type,
+      input.inlineComments(),
     ),
   )
+  const activeMessageID = createMemo(() => projection().activeMessageID)
   const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
-    reuseTimelineRows(
-      previous,
-      messageRowMemos().flatMap((memo) => memo()),
-    ),
+    reuseTimelineRows(previous, projection().rows),
   )
   const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
   const messageRowIndex = createMemo(() => {

+ 122 - 0
packages/app/src/pages/session/timeline/rows-current.test.ts

@@ -0,0 +1,122 @@
+import { describe, expect, mock, test } from "bun:test"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
+import { normalizeSessionMessages } from "@/utils/session-message"
+
+mock.module("@opencode-ai/session-ui/message-part", () => ({
+  renderable: () => true,
+  groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
+    refs.map((ref) => ({
+      type: "part" as const,
+      key: ref.part.id,
+      ref: { messageID: ref.messageID, partID: ref.part.id },
+    })),
+}))
+
+const { Timeline, TimelineRow } = await import("./rows")
+
+describe("current session timeline rows", () => {
+  test("derives turns and tagged rows from chronological current messages", () => {
+    const source = [
+      { id: "msg_1", type: "user", text: "first", time: { created: 1 } },
+      {
+        id: "msg_2",
+        type: "assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+        content: [{ type: "text", text: "answer" }],
+        time: { created: 2, completed: 3 },
+      },
+      { id: "msg_3", type: "user", text: "second", time: { created: 4 } },
+      {
+        id: "msg_4",
+        type: "assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+        content: [{ type: "reasoning", text: "working" }],
+        time: { created: 5 },
+      },
+    ] satisfies SessionMessageInfo[]
+    const normalized = normalizeSessionMessages("ses_1", source)
+    const messages = new Map(normalized.messages.map((message) => [message.id, message]))
+
+    const result = Timeline.constructSessionMessageRows(
+      source,
+      (messageID) => messages.get(messageID),
+      (messageID) => normalized.parts.get(messageID) ?? [],
+      true,
+      "busy",
+      true,
+    )
+
+    expect(result.activeMessageID).toBe("msg_3")
+    expect(result.rows.map(TimelineRow.key)).toEqual([
+      "user-message:msg_1",
+      "assistant-part:msg_1:msg_2:text:0",
+      "turn-gap:msg_3",
+      "user-message:msg_3",
+      "assistant-part:msg_3:msg_4:reasoning:0",
+    ])
+  })
+
+  test("renders a current shell message as a standalone turn", () => {
+    const source = [
+      {
+        id: "msg_shell",
+        type: "shell",
+        shellID: "shell_1",
+        command: "pwd",
+        status: "exited",
+        exit: 0,
+        output: { output: "/repo", cursor: 5, size: 5, truncated: false },
+        time: { created: 1, completed: 2 },
+      },
+    ] satisfies SessionMessageInfo[]
+    const normalized = normalizeSessionMessages("ses_1", source)
+    const messages = new Map(normalized.messages.map((message) => [message.id, message]))
+
+    const result = Timeline.constructSessionMessageRows(
+      source,
+      (messageID) => messages.get(messageID),
+      (messageID) => normalized.parts.get(messageID) ?? [],
+      true,
+      "idle",
+      true,
+    )
+
+    expect(result.activeMessageID).toBe("msg_shell")
+    expect(result.rows.map(TimelineRow.key)).toEqual([
+      "user-message:msg_shell",
+      "assistant-part:msg_shell:msg_shell:tool",
+    ])
+  })
+
+  test("associates assistants with a projected parent missing from the source page", () => {
+    const source = [
+      { id: "msg_user", type: "user", text: "question", time: { created: 1 } },
+      {
+        id: "msg_assistant",
+        type: "assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+        content: [{ type: "text", text: "answer" }],
+        time: { created: 2, completed: 3 },
+      },
+    ] satisfies SessionMessageInfo[]
+    const normalized = normalizeSessionMessages("ses_1", source)
+    const messages = new Map(normalized.messages.map((message) => [message.id, message]))
+
+    const result = Timeline.constructSessionMessageRows(
+      [source[1]!],
+      (messageID) => messages.get(messageID),
+      (messageID) => normalized.parts.get(messageID) ?? [],
+      true,
+      "idle",
+      true,
+    )
+
+    expect(result.rows.map(TimelineRow.key)).toEqual([
+      "user-message:msg_user",
+      "assistant-part:msg_user:msg_assistant:text:0",
+    ])
+  })
+})

+ 50 - 0
packages/app/src/pages/session/timeline/rows.ts

@@ -1,4 +1,5 @@
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
 import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
 import { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -31,6 +32,55 @@ export type TimelineRowMap = {
 }
 
 export namespace Timeline {
+  export function constructSessionMessageRows(
+    messages: SessionMessageInfo[],
+    getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
+    getMessageParts: (messageID: string) => Part[],
+    showReasoning: boolean,
+    status: SessionStatus["type"],
+    inlineComments: boolean,
+  ) {
+    const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => {
+      const projected = getMessage(message.id)
+      if (message.type === "shell" && projected?.role === "user") {
+        const assistant = getMessage(`${message.id}:assistant`)
+        return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }]
+      }
+      return projected?.role === "user" ? [{ user: projected, assistants: [] }] : []
+    })
+    const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn]))
+    messages.forEach((message) => {
+      const projected = getMessage(message.id)
+      if (projected?.role !== "assistant") return
+      const existing = turnByUserID.get(projected.parentID)
+      if (existing) {
+        existing.assistants.push(projected)
+        return
+      }
+      const user = getMessage(projected.parentID)
+      if (user?.role !== "user") return
+      const turn = { user, assistants: [projected] }
+      turns.push(turn)
+      turnByUserID.set(user.id, turn)
+    })
+    const activeMessageID = turns.at(-1)?.user.id
+    return {
+      activeMessageID,
+      rows: turns.flatMap((turn, index) =>
+        constructMessageRows(
+          turn.user,
+          getMessageParts,
+          turn.assistants,
+          index,
+          showReasoning,
+          status,
+          turn.user.id === activeMessageID,
+          inlineComments,
+        ),
+      ),
+    }
+  }
+
   export function constructMessageRows(
     userMessage: UserMessage,
     getMessageParts: (messageID: string) => Part[],

+ 7 - 5
packages/session-ui/src/components/message-part.tsx

@@ -521,6 +521,7 @@ export function getToolInfo(
       }
     }
     case "bash":
+    case "shell":
       return {
         icon: "console",
         title: i18n.t("ui.tool.shell"),
@@ -538,6 +539,7 @@ export function getToolInfo(
         title: i18n.t("ui.messagePart.title.write"),
         subtitle: input.filePath ? getFilename(input.filePath) : undefined,
       }
+    case "patch":
     case "apply_patch":
       return {
         icon: "code-lines",
@@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
 }
 
 function toolDefaultOpen(tool: string, shell = false, edit = false) {
-  if (tool === "bash") return shell
-  if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
+  if (tool === "bash" || tool === "shell") return shell
+  if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
 }
 
 export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
 }
 
 export function getTool(name: string) {
-  return state[name]?.render
+  return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
 }
 
 export const ToolRegistry = {
@@ -2101,7 +2103,7 @@ ToolRegistry.register({
 })
 
 ToolRegistry.register({
-  name: "bash",
+  name: "shell",
   render(props) {
     const i18n = useI18n()
     const pending = () => props.status === "pending" || props.status === "running"
@@ -2337,7 +2339,7 @@ ToolRegistry.register({
 })
 
 ToolRegistry.register({
-  name: "apply_patch",
+  name: "patch",
   render(props) {
     const i18n = useI18n()
     const fileComponent = useFileComponent()

+ 2 - 0
packages/session-ui/src/components/tool-error-card.tsx

@@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
       webfetch: "ui.tool.webfetch",
       websearch: "ui.tool.websearch",
       bash: "ui.tool.shell",
+      shell: "ui.tool.shell",
+      patch: "ui.tool.patch",
       apply_patch: "ui.tool.patch",
       question: "ui.tool.questions",
     }