From 75730c8d79dadbfc28d817d2eb134c3005ae2978 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:14:01 +0800 Subject: [PATCH 01/17] feat: simplify message steps display - Show tool operations in single line: status icon + tool name + key param - Add TOOL_LABELS mapping for friendly tool names (e.g., 'Fetch' instead of 'webfetch') - Extract key parameters (URL, file path, command) for each tool type - Color-coded status: green (completed), red (error), blue pulse (running) - Hide verbose output/details, todowrite shows only in sidebar Progress Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../app/components/session/message-list.tsx | 54 ++++++--- packages/app/src/app/utils/index.ts | 109 ++++++++++++++---- 2 files changed, 126 insertions(+), 37 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index a26a5771..ece454e3 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -183,32 +183,58 @@ export default function MessageList(props: MessageListProps) { return blocks; }); + const getToolStatus = (part: Part) => { + if (part.type !== "tool") return null; + const state = (part as any).state ?? {}; + return state.status as string | undefined; + }; + const StepsList = (listProps: { parts: Part[]; isUser: boolean }) => ( -
+
{(part) => { const summary = summarizeStep(part); + const status = getToolStatus(part); + const isCompleted = status === "completed"; + const isError = status === "error"; + const isRunning = status === "running"; + const statusClass = summary.isSkill + ? "text-purple-10" + : isCompleted + ? "text-green-10" + : isError + ? "text-red-10" + : isRunning + ? "text-blue-10" + : "text-gray-10"; + return ( -
-
- {summary.isSkill ? : part.type === "tool" ? : } +
+
+ {summary.isSkill ? ( + + ) : isCompleted ? ( + + ) : isError ? ( + + ) : isRunning ? ( + + ) : ( + + )}
-
-
- {summary.title} +
+
+ {summary.title} skill + + {summary.detail} +
- -
{summary.detail}
-
= { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", + patch: "Patch", + multiedit: "MultiEdit", + grep: "Grep", + glob: "Glob", + task: "Task", + webfetch: "Fetch", + fetchurl: "Fetch", + websearch: "Search", + execute: "Execute", + create: "Create", + ls: "List", + list: "List", + skill: "Skill", + todowrite: "Todo", +}; + +function extractToolKeyParam(toolKey: string, input: Record): string | null { + const paramPriority: Record = { + webfetch: ["url"], + fetchurl: ["url"], + websearch: ["query"], + read: ["file_path", "path"], + write: ["file_path", "path"], + edit: ["file_path", "path"], + create: ["file_path", "path"], + grep: ["pattern", "query"], + glob: ["pattern", "patterns"], + bash: ["command"], + execute: ["command"], + ls: ["directory_path", "path"], + list: ["directory_path", "path"], + }; + + const keys = paramPriority[toolKey] ?? Object.keys(input).slice(0, 1); + for (const key of keys) { + const val = input[key]; + if (typeof val === "string" && val.trim()) { + const trimmed = val.trim(); + return trimmed.length > 60 ? `${trimmed.slice(0, 60)}…` : trimmed; + } + if (Array.isArray(val) && val.length > 0) { + const first = String(val[0]); + return first.length > 60 ? `${first.slice(0, 60)}…` : first; + } + } + return null; +} + export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: string } { if (part.type === "tool") { const record = part as any; const toolName = record.tool ? String(record.tool) : "Tool"; + const toolKey = toolName.toLowerCase(); + const label = TOOL_LABELS[toolKey] ?? toolName; const state = record.state ?? {}; - const title = state.title ? String(state.title) : toolName; - const output = typeof state.output === "string" && state.output.trim() ? state.output.trim() : null; - - // Detect skill trigger - if (toolName === "skill") { - const skillName = state.metadata?.name || title.replace(/^Loaded skill:\s*/i, ""); - if (output) { - const short = output.length > 160 ? `${output.slice(0, 160)}…` : output; - return { title, isSkill: true, skillName, detail: short }; - } - return { title, isSkill: true, skillName }; + const input = typeof state.input === "object" && state.input ? state.input : {}; + + if (toolKey === "skill") { + const title = state.title ? String(state.title) : toolName; + const rawName = state.metadata?.name ?? title.replace(/^Loaded skill:\s*/i, ""); + const skillName = typeof rawName === "string" ? rawName.trim() : ""; + return { + title: label, + detail: skillName || undefined, + isSkill: true, + skillName: skillName || undefined, + }; } - - if (output) { - const short = output.length > 160 ? `${output.slice(0, 160)}…` : output; - return { title, detail: short }; + + if (toolKey === "todowrite") { + return { title: label }; } - return { title }; + + let keyParam = extractToolKeyParam(toolKey, input); + + if (!keyParam && state.title) { + const titleStr = + typeof state.title === "string" + ? state.title + : typeof state.title === "object" + ? JSON.stringify(state.title).slice(0, 80) + : String(state.title); + const title = titleStr.trim(); + keyParam = title.length > 80 ? `${title.slice(0, 80)}…` : title; + } + + return { title: label, detail: keyParam ?? undefined }; } if (part.type === "reasoning") { - const record = part as any; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return { title: "Planning" }; - const short = text.length > 120 ? `${text.slice(0, 120)}…` : text; - return { title: "Thinking", detail: short }; + return { title: "Thinking" }; } if (part.type === "step-start" || part.type === "step-finish") { - const reason = (part as any).reason; return { title: part.type === "step-start" ? "Step started" : "Step finished", - detail: reason ? String(reason) : undefined, }; } From 612336612791ffdc0b1bf58e19ffd7b96fd7a79b Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:18:23 +0800 Subject: [PATCH 02/17] fix: prevent scroll jump when toggling View steps Only scroll to bottom when messages count actually increases, not on every re-render or state change. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/pages/session.tsx | 38 ++++++++++---------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index a3aab7ff..f6535c64 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -502,29 +502,21 @@ export default function SessionView(props: SessionViewProps) { } }); - createEffect( - on( - () => [ - props.messages.length, - props.todos.length, - props.messages.reduce((acc, m) => acc + m.parts.length, 0), - ], - (current, previous) => { - if (!previous) return; - const [mLen, tLen, pCount] = current; - const [prevM, prevT, prevP] = previous; - if (mLen > prevM || tLen > prevT || pCount > prevP) { - const shouldScroll = scrollOnNextUpdate() || autoScrollEnabled(); - if (shouldScroll) { - scrollToLatest(scrollOnNextUpdate() ? "smooth" : "auto"); - } - if (scrollOnNextUpdate()) { - setScrollOnNextUpdate(false); - } - } - }, - ), - ); + const [prevMessageCount, setPrevMessageCount] = createSignal(0); + createEffect(() => { + const currentCount = props.messages.length; + const prev = prevMessageCount(); + if (currentCount > prev) { + const shouldScroll = scrollOnNextUpdate() || autoScrollEnabled(); + if (shouldScroll) { + scrollToLatest(scrollOnNextUpdate() ? "smooth" : "auto"); + } + if (scrollOnNextUpdate()) { + setScrollOnNextUpdate(false); + } + } + setPrevMessageCount(currentCount); + }); const triggerFlyout = ( sourceEl: Element | null, From ca319cc4bcc587ff7b345e26dcefcb76fc4fb948 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:24:08 +0800 Subject: [PATCH 03/17] feat: enhance tool step display with smarter formatting - List (LS): Show only last 3 path segments instead of full path - Read: Show file path + line range info (e.g., 'from L100, 50 lines') - Grep/Glob: Show pattern in quotes + search path context - Execute: Show first line of command, truncated - Edit/Write/Create: Show shortened file path - GitHub icon: Display for git/gh commands (MCP or CLI) - Add StepSummary type with icon field for extensibility Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .gitignore | 4 + .../app/components/session/message-list.tsx | 77 ++++--- packages/app/src/app/utils/index.ts | 193 ++++++++++++++---- 3 files changed, 209 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 00c3f22c..a72cf013 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ vendor/opencode/ # OpenCode local deps .opencode/node_modules/ .opencode/bun.lock + +# Local notes +CLAUDE.md +openwork_security_audit.md diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index ece454e3..a6b4d053 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -1,10 +1,10 @@ import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"; import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; -import { Check, ChevronDown, Circle, Copy, File, Sparkles } from "lucide-solid"; +import { Check, ChevronDown, Circle, Copy, File, Github, Sparkles } from "lucide-solid"; import type { MessageGroup, MessageWithParts } from "../../types"; -import { groupMessageParts, summarizeStep } from "../../utils"; +import { groupMessageParts, summarizeStep, type StepSummary } from "../../utils"; import PartView from "../part-view"; export type MessageListProps = { @@ -189,40 +189,61 @@ export default function MessageList(props: MessageListProps) { return state.status as string | undefined; }; + const renderStepIcon = (summary: StepSummary, status: string | null | undefined) => { + const isCompleted = status === "completed"; + const isError = status === "error"; + const isRunning = status === "running"; + + if (summary.isSkill) { + const colorClass = isError + ? "text-red-10" + : isCompleted + ? "text-green-10" + : "text-purple-10"; + return ( +
+ +
+ ); + } + + // GitHub icon for git-related operations + if (summary.icon === "github") { + return ( +
+ +
+ ); + } + + // Default status icons + return ( +
+ {isCompleted ? : + isError ? : + isRunning ? : + } +
+ ); + }; + const StepsList = (listProps: { parts: Part[]; isUser: boolean }) => (
{(part) => { const summary = summarizeStep(part); const status = getToolStatus(part); - const isCompleted = status === "completed"; - const isError = status === "error"; - const isRunning = status === "running"; - const statusClass = summary.isSkill - ? "text-purple-10" - : isCompleted - ? "text-green-10" - : isError - ? "text-red-10" - : isRunning - ? "text-blue-10" - : "text-gray-10"; - return (
-
- {summary.isSkill ? ( - - ) : isCompleted ? ( - - ) : isError ? ( - - ) : isRunning ? ( - - ) : ( - - )} -
+ {renderStepIcon(summary, status)}
{summary.title} diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index 5aadeb28..8c32496c 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -487,66 +487,181 @@ const TOOL_LABELS: Record = { todowrite: "Todo", }; -function extractToolKeyParam(toolKey: string, input: Record): string | null { - const paramPriority: Record = { - webfetch: ["url"], - fetchurl: ["url"], - websearch: ["query"], - read: ["file_path", "path"], - write: ["file_path", "path"], - edit: ["file_path", "path"], - create: ["file_path", "path"], - grep: ["pattern", "query"], - glob: ["pattern", "patterns"], - bash: ["command"], - execute: ["command"], - ls: ["directory_path", "path"], - list: ["directory_path", "path"], - }; +// Tools that should show GitHub icon (git operations) +const GITHUB_TOOLS = new Set([ + "git", + "gh", + "github", + "mcp_github", + "mcp-github", + "git_status", + "git_diff", + "git_log", + "git_commit", + "git_push", + "git_pull", + "create_pull_request", + "list_pull_requests", + "get_pull_request", + "create_issue", + "list_issues", + "get_issue", + "create_branch", + "list_branches", + "create_repository", +]); + +// Shorten path to last N segments +function shortenPath(path: string, segments = 3): string { + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + if (parts.length <= segments) return path; + return parts.slice(-segments).join("/"); +} - const keys = paramPriority[toolKey] ?? Object.keys(input).slice(0, 1); - for (const key of keys) { - const val = input[key]; - if (typeof val === "string" && val.trim()) { - const trimmed = val.trim(); - return trimmed.length > 60 ? `${trimmed.slice(0, 60)}…` : trimmed; - } - if (Array.isArray(val) && val.length > 0) { - const first = String(val[0]); - return first.length > 60 ? `${first.slice(0, 60)}…` : first; - } +// Format file size or line count +function formatReadInfo(input: Record): string | null { + const parts: string[] = []; + + // Get file path (shortened) + const filePath = input.file_path ?? input.path; + if (typeof filePath === "string" && filePath.trim()) { + parts.push(shortenPath(filePath.trim())); + } + + // Add line range info if present + const offset = input.offset ?? input.start_line; + const limit = input.limit ?? input.end_line ?? input.lines; + if (typeof offset === "number" || typeof limit === "number") { + const rangeInfo: string[] = []; + if (typeof offset === "number" && offset > 0) rangeInfo.push(`from L${offset}`); + if (typeof limit === "number") rangeInfo.push(`${limit} lines`); + if (rangeInfo.length) parts.push(`(${rangeInfo.join(", ")})`); + } + + return parts.length ? parts.join(" ") : null; +} + +// Format list directory info +function formatListInfo(input: Record): string | null { + const dirPath = input.directory_path ?? input.path ?? input.folder; + if (typeof dirPath === "string" && dirPath.trim()) { + return shortenPath(dirPath.trim()); } return null; } -export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: string } { +// Format search info (grep/glob) +function formatSearchInfo(toolName: string, input: Record): string | null { + const parts: string[] = []; + + // Pattern + const pattern = input.pattern ?? input.query ?? input.patterns; + if (typeof pattern === "string" && pattern.trim()) { + const p = pattern.trim(); + parts.push(p.length > 30 ? `"${p.slice(0, 30)}…"` : `"${p}"`); + } else if (Array.isArray(pattern) && pattern.length > 0) { + const first = String(pattern[0]); + parts.push(first.length > 30 ? `"${first.slice(0, 30)}…"` : `"${first}"`); + } + + // Path context + const path = input.path ?? input.folder ?? input.directory; + if (typeof path === "string" && path.trim()) { + parts.push(`in ${shortenPath(path.trim(), 2)}`); + } + + // File type filter + const fileType = input.type ?? input.glob_pattern; + if (typeof fileType === "string" && fileType.trim()) { + parts.push(`(${fileType})`); + } + + return parts.length ? parts.join(" ") : null; +} + +// Format command/execute info +function formatCommandInfo(input: Record): string | null { + const cmd = input.command ?? input.cmd; + if (typeof cmd === "string" && cmd.trim()) { + const trimmed = cmd.trim(); + // Show first line only, truncate if too long + const firstLine = trimmed.split("\n")[0]; + return firstLine.length > 50 ? `${firstLine.slice(0, 50)}…` : firstLine; + } + return null; +} + +export type StepSummary = { + title: string; + detail?: string; + icon?: "github" | "default"; + isSkill?: boolean; + skillName?: string; +}; + +export function summarizeStep(part: Part): StepSummary { if (part.type === "tool") { const record = part as any; const toolName = record.tool ? String(record.tool) : "Tool"; - const toolKey = toolName.toLowerCase(); - const label = TOOL_LABELS[toolKey] ?? toolName; + const toolLower = toolName.toLowerCase(); + const label = TOOL_LABELS[toolLower] ?? toolName; const state = record.state ?? {}; const input = typeof state.input === "object" && state.input ? state.input : {}; - if (toolKey === "skill") { + const isGithubTool = + GITHUB_TOOLS.has(toolLower) || + toolLower.includes("github") || + toolLower.includes("git_") || + toolLower.startsWith("gh_") || + (toolLower === "execute" && + typeof input.command === "string" && + (input.command.startsWith("git ") || input.command.startsWith("gh "))); + + if (toolLower === "skill") { const title = state.title ? String(state.title) : toolName; const rawName = state.metadata?.name ?? title.replace(/^Loaded skill:\s*/i, ""); const skillName = typeof rawName === "string" ? rawName.trim() : ""; return { title: label, detail: skillName || undefined, + icon: "default", isSkill: true, skillName: skillName || undefined, }; } - if (toolKey === "todowrite") { - return { title: label }; + if (toolLower === "todowrite") { + return { title: label, icon: isGithubTool ? "github" : "default" }; } - let keyParam = extractToolKeyParam(toolKey, input); + let detail: string | null = null; + + if (toolLower === "read") { + detail = formatReadInfo(input); + } else if (toolLower === "ls" || toolLower === "list") { + detail = formatListInfo(input); + } else if (["grep", "glob", "find"].includes(toolLower)) { + detail = formatSearchInfo(toolLower, input); + } else if (["bash", "execute", "shell"].includes(toolLower)) { + detail = formatCommandInfo(input); + } else if (["edit", "write", "create", "patch", "multiedit"].includes(toolLower)) { + const filePath = input.file_path ?? input.path; + if (typeof filePath === "string" && filePath.trim()) { + detail = shortenPath(filePath.trim()); + } + } else if (["webfetch", "fetchurl", "websearch"].includes(toolLower)) { + const url = input.url; + const query = input.query; + if (typeof url === "string" && url.trim()) { + const u = url.trim(); + detail = u.length > 50 ? `${u.slice(0, 50)}…` : u; + } else if (typeof query === "string" && query.trim()) { + const q = query.trim(); + detail = q.length > 40 ? `"${q.slice(0, 40)}…"` : `"${q}"`; + } + } - if (!keyParam && state.title) { + if (!detail && state.title) { const titleStr = typeof state.title === "string" ? state.title @@ -554,10 +669,14 @@ export function summarizeStep(part: Part): { title: string; detail?: string; isS ? JSON.stringify(state.title).slice(0, 80) : String(state.title); const title = titleStr.trim(); - keyParam = title.length > 80 ? `${title.slice(0, 80)}…` : title; + detail = title.length > 60 ? `${title.slice(0, 60)}…` : title; } - return { title: label, detail: keyParam ?? undefined }; + return { + title: label, + detail: detail ?? undefined, + icon: isGithubTool ? "github" : "default", + }; } if (part.type === "reasoning") { From 60a1b7de2e5ab5b341c802d01fd28fd1f962d9d2 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:27:08 +0800 Subject: [PATCH 04/17] fix: hide empty steps (step-start/step-finish markers) Only show View steps when there are actual tool or reasoning parts, ignore step-start/step-finish metadata markers that have no content. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/utils/index.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index 8c32496c..311d0f44 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -421,7 +421,8 @@ export function lastUserModelFromMessages(list: MessageWithParts[]): ModelRef | } export function isStepPart(part: Part) { - return part.type === "reasoning" || part.type === "tool" || part.type === "step-start" || part.type === "step-finish"; + // Only count reasoning and tool as real steps, ignore step-start/step-finish markers + return part.type === "reasoning" || part.type === "tool"; } export function groupMessageParts(parts: Part[], messageId: string): MessageGroup[] { @@ -683,12 +684,6 @@ export function summarizeStep(part: Part): StepSummary { return { title: "Thinking" }; } - if (part.type === "step-start" || part.type === "step-finish") { - return { - title: part.type === "step-start" ? "Step started" : "Step finished", - }; - } - return { title: "Step" }; } From d6b7939b090d29ebe18a6c7fff5325074c589892 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:29:37 +0800 Subject: [PATCH 05/17] fix: user message bubble auto-width based on content Remove w-full from user bubbles so they shrink to fit content, assistant messages keep w-full for consistent width. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/components/session/message-list.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index a6b4d053..2090aafb 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -288,10 +288,10 @@ export default function MessageList(props: MessageListProps) { data-message-id={block.messageIds[0] ?? ""} >
@@ -344,10 +344,10 @@ export default function MessageList(props: MessageListProps) { data-message-id={block.messageId} >
0}> From 74779e851b87e9b2adb8af4b8ba6d1dc41293209 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:31:17 +0800 Subject: [PATCH 06/17] fix: reduce user bubble padding for more compact look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - px-6 py-4 → px-5 py-3 (smaller padding) - rounded-[24px] → rounded-[20px] (proportional) - leading-relaxed → leading-normal (tighter line height) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/components/session/message-list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 2090aafb..feaa7b74 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -290,7 +290,7 @@ export default function MessageList(props: MessageListProps) {
@@ -346,7 +346,7 @@ export default function MessageList(props: MessageListProps) {
From aa0c6bdbf08d560bb7336512186e6a7ee974ef24 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:34:10 +0800 Subject: [PATCH 07/17] fix: move copy button outside user bubble - User messages: copy button on left side of bubble - Assistant messages: copy button inside at bottom right - Both show on hover only Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../app/components/session/message-list.tsx | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index feaa7b74..8807c4ef 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -337,12 +337,31 @@ export default function MessageList(props: MessageListProps) { } const groupSpacing = block.isUser ? "mb-3" : "mb-4"; + + const copyButton = ( + + ); + return (
+ {copyButton}
)} -
- -
+ +
+ {copyButton} +
+
); From 56cb3048bafde8826ca82edb1f90e95f8327602e Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:41:03 +0800 Subject: [PATCH 08/17] feat: improve time display format - Running time: show as seconds (e.g., '30s'), minutes ('2m 15s'), hours ('1h 30m') - Completed messages: show total duration with clock icon - Remove milliseconds display for cleaner look Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../app/components/session/message-list.tsx | 28 +++++++++++++++---- packages/app/src/app/pages/session.tsx | 21 +++++++++++++- packages/app/src/app/utils/index.ts | 19 +++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 8807c4ef..0cc19a33 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -1,10 +1,10 @@ import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"; import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; -import { Check, ChevronDown, Circle, Copy, File, Github, Sparkles } from "lucide-solid"; +import { Check, ChevronDown, Circle, Clock, Copy, File, Github, Sparkles } from "lucide-solid"; import type { MessageGroup, MessageWithParts } from "../../types"; -import { groupMessageParts, summarizeStep, type StepSummary } from "../../utils"; +import { formatElapsedTime, groupMessageParts, summarizeStep, type StepSummary } from "../../utils"; import PartView from "../part-view"; export type MessageListProps = { @@ -445,9 +445,27 @@ export default function MessageList(props: MessageListProps) { )} -
- {copyButton} -
+ {(() => { + const info = block.message.info as any; + const created = info?.time?.created; + const completed = info?.time?.completed; + const duration = created && completed ? completed - created : null; + + return ( +
+ 0}> +
+ + {formatElapsedTime(duration!)} +
+
+
+
+ {copyButton} +
+
+ ); + })()}
diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index f6535c64..1ab8797a 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -451,7 +451,26 @@ export default function SessionView(props: SessionViewProps) { return Math.max(0, runTick() - start); }); - const runElapsedLabel = createMemo(() => `${Math.round(runElapsedMs()).toLocaleString()}ms`); + const formatElapsedTime = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + } + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + if (seconds > 0) { + return `${seconds}s`; + } + return `${ms}ms`; + }; + + const runElapsedLabel = createMemo(() => formatElapsedTime(runElapsedMs())); onMount(() => { setTimeout(() => setIsInitialLoad(false), 2000); diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index 311d0f44..4230f5d7 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -237,6 +237,25 @@ export function formatRelativeTime(timestampMs: number) { return new Date(timestampMs).toLocaleDateString(); } +export function formatElapsedTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + } + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + if (seconds > 0) { + return `${seconds}s`; + } + return `${ms}ms`; +} + export function commandPathFromWorkspaceRoot(workspaceRoot: string, commandName: string) { const root = workspaceRoot.trim().replace(/\/+$/, ""); const name = commandName.trim().replace(/^\/+/, ""); From 071ce6fb4aa2c6d9fd3fc2ba172ba5b9ff1a85a5 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:43:49 +0800 Subject: [PATCH 09/17] fix: show duration only on last assistant message Calculate total duration from first assistant message creation to last assistant message completion, display only once at the end. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../app/components/session/message-list.tsx | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 0cc19a33..17cd2ddb 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -183,6 +183,37 @@ export default function MessageList(props: MessageListProps) { return blocks; }); + // Calculate total duration from first assistant message to last completed assistant message + const sessionDuration = createMemo(() => { + const assistantMessages = props.messages.filter( + (m) => (m.info as any).role === "assistant" + ); + if (assistantMessages.length === 0) return null; + + const firstAssistant = assistantMessages[0]; + const lastAssistant = assistantMessages[assistantMessages.length - 1]; + + const firstCreated = (firstAssistant.info as any)?.time?.created; + const lastCompleted = (lastAssistant.info as any)?.time?.completed; + + if (!firstCreated || !lastCompleted) return null; + + const duration = lastCompleted - firstCreated; + return duration > 0 ? duration : null; + }); + + // Find the last assistant message block + const lastAssistantBlockId = createMemo(() => { + const blocks = messageBlocks(); + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.kind === "message" && !block.isUser) { + return block.messageId; + } + } + return null; + }); + const getToolStatus = (part: Part) => { if (part.type !== "tool") return null; const state = (part as any).state ?? {}; @@ -446,10 +477,8 @@ export default function MessageList(props: MessageListProps) { {(() => { - const info = block.message.info as any; - const created = info?.time?.created; - const completed = info?.time?.completed; - const duration = created && completed ? completed - created : null; + const isLastAssistant = block.messageId === lastAssistantBlockId(); + const duration = isLastAssistant ? sessionDuration() : null; return (
From 8d9e0ee522576558e003368f57936b6b80cc3bdb Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:50:04 +0800 Subject: [PATCH 10/17] feat: add send/stop button in composer - Show send button (arrow) when idle - Show stop button (square) when busy/running - Stop button calls session.abort() to cancel the run - Red color for stop button to indicate destructive action Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/app.tsx | 18 +++++++++ .../src/app/components/session/composer.tsx | 38 +++++++++++++------ packages/app/src/app/pages/session.tsx | 2 + 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 02390e6a..e3673765 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -808,6 +808,23 @@ export default function App() { } } + async function cancelRun() { + const c = client(); + const sessionID = selectedSessionId(); + if (!c || !sessionID) return; + + try { + await c.session.abort({ sessionID }); + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + setError(message); + } finally { + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + } + } + async function renameSessionTitle(sessionID: string, title: string) { const trimmed = title.trim(); if (!trimmed) { @@ -4184,6 +4201,7 @@ export default function App() { busy: busy(), prompt: prompt(), setPrompt: setPrompt, + cancelRun: cancelRun, activePermission: activePermissionMemo(), permissionReplyBusy: permissionReplyBusy(), respondPermission: respondPermission, diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index bb39c4ec..5930cc09 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -1,6 +1,6 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import type { Agent } from "@opencode-ai/sdk/v2/client"; -import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid"; +import { ArrowRight, AtSign, ChevronDown, File, Paperclip, Square, X, Zap } from "lucide-solid"; import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types"; @@ -28,6 +28,7 @@ type ComposerProps = { busy: boolean; onSend: (draft: ComposerDraft) => void; onDraftChange: (draft: ComposerDraft) => void; + onCancel: () => void; commandMatches: CommandItem[]; onRunCommand: (commandId: string) => void; onInsertCommand: (commandId: string) => void; @@ -1162,18 +1163,31 @@ export default function Composer(props: ComposerProps) { - + } > - - + +
diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 1ab8797a..3fb040dc 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -85,6 +85,7 @@ export type SessionViewProps = { busy: boolean; prompt: string; setPrompt: (value: string) => void; + cancelRun: () => Promise; selectedSessionModelLabel: string; openSessionModelPicker: () => void; modelVariantLabel: string; @@ -1364,6 +1365,7 @@ export default function SessionView(props: SessionViewProps) { prompt={props.prompt} busy={props.busy} onSend={handleSendPrompt} + onCancel={() => props.cancelRun()} onDraftChange={handleDraftChange} commandMatches={commandMatches()} onRunCommand={handleRunCommand} From 84db84203d732d1af581a8796ebafef86d200cf1 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:53:20 +0800 Subject: [PATCH 11/17] fix: make send button visible when disabled Change opacity from 0 to 30% so button is always visible. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/components/session/composer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index 5930cc09..36ce8015 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -1171,7 +1171,7 @@ export default function Composer(props: ComposerProps) { onClick={sendDraft} class={`p-2 rounded-xl transition-all shadow-lg shrink-0 flex items-center justify-center ${ !props.prompt.trim() && !attachments().length - ? "bg-gray-4 text-gray-8 cursor-not-allowed" + ? "bg-gray-4 text-gray-8 cursor-not-allowed opacity-30" : "bg-gray-12 text-gray-1 hover:scale-105 active:scale-95" }`} title="Run" From 7c03855aa3b5b3e07d4633969667cec0657b8fd6 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 17:55:33 +0800 Subject: [PATCH 12/17] fix: show duration per message instead of total session time Each assistant message now shows its own duration (created to completed), not the total time from first to last message. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../app/components/session/message-list.tsx | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 17cd2ddb..10da71f8 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -183,36 +183,15 @@ export default function MessageList(props: MessageListProps) { return blocks; }); - // Calculate total duration from first assistant message to last completed assistant message - const sessionDuration = createMemo(() => { - const assistantMessages = props.messages.filter( - (m) => (m.info as any).role === "assistant" - ); - if (assistantMessages.length === 0) return null; - - const firstAssistant = assistantMessages[0]; - const lastAssistant = assistantMessages[assistantMessages.length - 1]; - - const firstCreated = (firstAssistant.info as any)?.time?.created; - const lastCompleted = (lastAssistant.info as any)?.time?.completed; - - if (!firstCreated || !lastCompleted) return null; - - const duration = lastCompleted - firstCreated; + // Get duration for a specific message (created to completed) + const getMessageDuration = (message: MessageWithParts): number | null => { + const info = message.info as any; + const created = info?.time?.created; + const completed = info?.time?.completed; + if (!created || !completed) return null; + const duration = completed - created; return duration > 0 ? duration : null; - }); - - // Find the last assistant message block - const lastAssistantBlockId = createMemo(() => { - const blocks = messageBlocks(); - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i]; - if (block.kind === "message" && !block.isUser) { - return block.messageId; - } - } - return null; - }); + }; const getToolStatus = (part: Part) => { if (part.type !== "tool") return null; @@ -477,8 +456,7 @@ export default function MessageList(props: MessageListProps) { {(() => { - const isLastAssistant = block.messageId === lastAssistantBlockId(); - const duration = isLastAssistant ? sessionDuration() : null; + const duration = getMessageDuration(block.message); return (
From ab2c644fcf96f1cd7a2726c3b81997bad484a51e Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 18:41:03 +0800 Subject: [PATCH 13/17] feat: enhance message timing system with end reasons - Add MessageEndReason type (completed/interrupted/terminated/error) - Add MessageTiming type with startAt/lastTokenAt/endAt/endReason - Track timing from part updates for more accurate duration - Show end reason labels for non-completed messages - Support cluster timing aggregation for grouped steps - Mark session end reason on abort for proper interruption tracking Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/app/src/app/app.tsx | 4 + .../app/components/session/message-list.tsx | 118 +++++++++++++++--- packages/app/src/app/context/session.ts | 106 ++++++++++++++++ packages/app/src/app/pages/session.tsx | 3 + packages/app/src/app/types.ts | 9 ++ packages/app/src/app/utils/index.ts | 26 +++- 6 files changed, 246 insertions(+), 20 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index e3673765..67c0f321 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -617,6 +617,7 @@ export default function App() { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -627,6 +628,7 @@ export default function App() { selectSession, renameSession, respondPermission, + markSessionEndReason, setSessions, setSessionStatusById, setMessages, @@ -814,6 +816,7 @@ export default function App() { if (!c || !sessionID) return; try { + markSessionEndReason(sessionID, "interrupted"); await c.session.abort({ sessionID }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); @@ -4185,6 +4188,7 @@ export default function App() { })), selectSession: selectSession, messages: activeMessages(), + messageTimings: isDemoMode() ? {} : messageTimings(), todos: activeTodos(), busyLabel: busyLabel(), developerMode: developerMode(), diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 10da71f8..a39380f8 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -3,12 +3,13 @@ import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; import { Check, ChevronDown, Circle, Clock, Copy, File, Github, Sparkles } from "lucide-solid"; -import type { MessageGroup, MessageWithParts } from "../../types"; +import type { MessageEndReason, MessageGroup, MessageInfo, MessageTiming, MessageWithParts } from "../../types"; import { formatElapsedTime, groupMessageParts, summarizeStep, type StepSummary } from "../../utils"; import PartView from "../part-view"; export type MessageListProps = { messages: MessageWithParts[]; + messageTimings: Record; developerMode: boolean; showThinking: boolean; expandedStepIds: Set; @@ -183,14 +184,84 @@ export default function MessageList(props: MessageListProps) { return blocks; }); - // Get duration for a specific message (created to completed) - const getMessageDuration = (message: MessageWithParts): number | null => { - const info = message.info as any; - const created = info?.time?.created; - const completed = info?.time?.completed; - if (!created || !completed) return null; - const duration = completed - created; - return duration > 0 ? duration : null; + const messageInfoById = createMemo(() => { + const map = new Map(); + for (const message of props.messages) { + const id = String((message.info as any)?.id ?? ""); + if (id) { + map.set(id, message.info); + } + } + return map; + }); + + const reasonPriority: Record = { + terminated: 3, + interrupted: 2, + error: 2, + completed: 1, + }; + + const pickReason = ( + current: MessageEndReason | undefined, + next: MessageEndReason | undefined, + ) => { + if (!next) return current; + if (!current) return next; + return reasonPriority[next] > reasonPriority[current] ? next : current; + }; + + const resolveTimingRange = (messageId: string, info?: MessageInfo) => { + const timing = props.messageTimings[messageId]; + const created = (info as any)?.time?.created; + const completed = (info as any)?.time?.completed; + const start = timing?.startAt ?? (typeof created === "number" ? created : null); + const end = timing?.endAt ?? (typeof completed === "number" ? completed : null); + if (typeof start !== "number" || typeof end !== "number") return null; + const duration = end - start; + if (duration <= 0) return null; + const reason = timing?.endReason ?? (typeof completed === "number" ? "completed" : undefined); + return { start, end, duration, reason }; + }; + + const resolveMessageTiming = (message: MessageWithParts) => { + const info = message.info as MessageInfo; + const messageId = String((info as any)?.id ?? ""); + return resolveTimingRange(messageId, info); + }; + + const resolveClusterTiming = (messageIds: string[]) => { + const infoMap = messageInfoById(); + let start: number | null = null; + let end: number | null = null; + let reason: MessageEndReason | undefined; + + for (const messageId of messageIds) { + const info = infoMap.get(messageId); + const timing = resolveTimingRange(messageId, info); + if (!timing) continue; + start = start === null ? timing.start : Math.min(start, timing.start); + end = end === null ? timing.end : Math.max(end, timing.end); + reason = pickReason(reason, timing.reason); + } + + if (start === null || end === null) return null; + const duration = end - start; + if (duration <= 0) return null; + return { duration, reason }; + }; + + const formatReasonLabel = (reason: MessageEndReason) => { + switch (reason) { + case "terminated": + return "Terminated"; + case "interrupted": + return "Interrupted"; + case "error": + return "Error"; + default: + return ""; + } }; const getToolStatus = (part: Part) => { @@ -291,6 +362,7 @@ export default function MessageList(props: MessageListProps) { if (block.kind === "steps-cluster") { const relatedStepIds = block.stepIds.filter((stepId) => stepId !== block.id); const expanded = () => isStepsExpanded(block.id, relatedStepIds); + const clusterTiming = () => resolveClusterTiming(block.messageIds); return (
+ + {(timing) => ( +
+ + {formatElapsedTime(timing().duration)} + + · {formatReasonLabel(timing().reason!)} + +
+ )} +
{(() => { - const duration = getMessageDuration(block.message); + const timing = () => resolveMessageTiming(block.message); return (
- 0}> -
- - {formatElapsedTime(duration!)} -
+ + {(resolved) => ( +
+ + {formatElapsedTime(resolved().duration)} + + · {formatReasonLabel(resolved().reason!)} + +
+ )}
diff --git a/packages/app/src/app/context/session.ts b/packages/app/src/app/context/session.ts index 8f4d5e1d..b6625abe 100644 --- a/packages/app/src/app/context/session.ts +++ b/packages/app/src/app/context/session.ts @@ -6,6 +6,8 @@ import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; import type { Client, MessageInfo, + MessageEndReason, + MessageTiming, MessageWithParts, ModelRef, OpencodeEvent, @@ -37,6 +39,7 @@ type StoreState = { sessionStatus: Record; messages: Record; parts: Record; + messageTimings: Record; todos: Record; pendingPermissions: PendingPermission[]; events: OpencodeEvent[]; @@ -103,6 +106,35 @@ const upsertPartInfo = (list: Part[], next: Part) => { const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID); +const resolvePartTimestamp = (part: Part) => { + const record = part as Record; + const time = record.time as { created?: unknown; updated?: unknown } | undefined; + const created = time?.created; + const updated = time?.updated; + if (typeof created === "number") return created; + if (typeof updated === "number") return updated; + return Date.now(); +}; + +const resolveEndReasonFromStatus = (status: unknown): MessageEndReason | null => { + if (!status) return null; + if (typeof status === "string") { + const normalized = status.toLowerCase(); + if (["terminated", "terminate", "killed"].includes(normalized)) return "terminated"; + if (["interrupt", "interrupted", "aborted", "cancelled", "canceled"].includes(normalized)) { + return "interrupted"; + } + if (["error", "failed", "failure"].includes(normalized)) return "error"; + return null; + } + if (typeof status === "object") { + const record = status as Record; + const type = typeof record.type === "string" ? record.type.toLowerCase() : null; + return type ? resolveEndReasonFromStatus(type) : null; + } + return null; +}; + export function createSessionStore(options: { client: () => Client | null; selectedSessionId: () => string | null; @@ -120,12 +152,14 @@ export function createSessionStore(options: { sessionStatus: {}, messages: {}, parts: {}, + messageTimings: {}, todos: {}, pendingPermissions: [], events: [], }); const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false); const reloadDetectionSet = new Set(); + const sessionEndHints = new Map(); const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i; const skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i; @@ -263,6 +297,7 @@ export function createSessionStore(options: { const sessionStatusById = () => store.sessionStatus; const pendingPermissions = () => store.pendingPermissions; const events = () => store.events; + const messageTimings = () => store.messageTimings; const selectedSession = createMemo(() => { const id = options.selectedSessionId(); @@ -276,6 +311,37 @@ export function createSessionStore(options: { return store.sessionStatus[id] ?? "idle"; }); + const finalizePendingMessageTimings = (sessionID: string, reason: MessageEndReason) => { + setStore( + produce((draft: StoreState) => { + const list = draft.messages[sessionID] ?? []; + for (const info of list) { + if ((info as any)?.role !== "assistant") continue; + const timing = draft.messageTimings[info.id]; + if (!timing || timing.endAt || !timing.startAt) continue; + const endAt = timing.lastTokenAt ?? Date.now(); + timing.endAt = endAt; + timing.endReason = reason; + draft.messageTimings[info.id] = timing; + } + }), + ); + }; + + const markSessionEndReason = (sessionID: string, reason: MessageEndReason) => { + if (!sessionID) return; + sessionEndHints.set(sessionID, reason); + }; + + const consumeSessionEndReason = (sessionID: string) => { + if (!sessionID) return null; + const reason = sessionEndHints.get(sessionID) ?? null; + if (reason) { + sessionEndHints.delete(sessionID); + } + return reason; + }; + const messages = createMemo(() => { const id = options.selectedSessionId(); if (!id) return []; @@ -508,6 +574,11 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, normalizeSessionStatus(record.status)); + const endReason = resolveEndReasonFromStatus(record.status); + if (endReason) { + consumeSessionEndReason(sessionID); + finalizePendingMessageTimings(sessionID, endReason); + } } } } @@ -518,6 +589,8 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, "idle"); + const hintedReason = consumeSessionEndReason(sessionID); + finalizePendingMessageTimings(sessionID, hintedReason ?? "completed"); } } } @@ -584,6 +657,14 @@ export function createSessionStore(options: { } setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info)); + const completed = (info as any)?.time?.completed; + if (typeof completed === "number") { + setStore("messageTimings", info.id, (current: MessageTiming = {}) => ({ + ...current, + endAt: completed, + endReason: "completed" as MessageEndReason, + })); + } } } } @@ -596,6 +677,11 @@ export function createSessionStore(options: { if (sessionID && messageID) { setStore("messages", sessionID, (current = []) => removeMessageInfo(current, messageID)); setStore("parts", messageID, []); + setStore("messageTimings", (current) => { + const next = { ...current }; + delete next[messageID]; + return next; + }); } } } @@ -606,6 +692,7 @@ export function createSessionStore(options: { if (record.part && typeof record.part === "object") { const part = record.part as Part; const delta = typeof record.delta === "string" ? record.delta : null; + const partTime = resolvePartTimestamp(part); setStore( produce((draft: StoreState) => { @@ -628,6 +715,23 @@ export function createSessionStore(options: { } draft.parts[part.messageID] = upsertPartInfo(parts, part); + + const timing = draft.messageTimings[part.messageID] ?? {}; + if (!timing.startAt || partTime < timing.startAt) { + timing.startAt = partTime; + } + if (!timing.lastTokenAt || partTime > timing.lastTokenAt) { + timing.lastTokenAt = partTime; + } + if ( + timing.endAt && + timing.endReason && + timing.endReason !== "completed" && + partTime > timing.endAt + ) { + timing.endAt = partTime; + } + draft.messageTimings[part.messageID] = timing; }), ); maybeMarkReloadRequired(part); @@ -803,6 +907,7 @@ export function createSessionStore(options: { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -813,6 +918,7 @@ export function createSessionStore(options: { selectSession, renameSession, respondPermission, + markSessionEndReason, setSessions, setSessionStatusById, setMessages, diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 3fb040dc..59d10b61 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -7,6 +7,7 @@ import type { CommandRegistryItem, CommandTriggerContext, MessageGroup, + MessageTiming, MessageWithParts, McpServerEntry, McpStatusMap, @@ -60,6 +61,7 @@ export type SessionViewProps = { sessions: Array<{ id: string; title: string; slug?: string | null; workspaceLabel?: string | null }>; selectSession: (sessionId: string) => Promise | void; messages: MessageWithParts[]; + messageTimings: Record; todos: TodoItem[]; busyLabel: string | null; developerMode: boolean; @@ -1247,6 +1249,7 @@ export default function SessionView(props: SessionViewProps) { ; - if (record.type === "busy") return "running"; - if (record.type === "retry") return "retry"; - if (record.type === "idle") return "idle"; + const resolveType = (value: unknown) => { + if (!value) return null; + if (typeof value === "string") return value.toLowerCase(); + if (typeof value === "object") { + const record = value as Record; + if (typeof record.type === "string") return record.type.toLowerCase(); + } + return null; + }; + + const type = resolveType(status); + if (!type) return "idle"; + + if (type === "busy" || type === "running") return "running"; + if (type === "retry") return "retry"; + if (type === "idle") return "idle"; + + if (["terminated", "terminate", "killed"].includes(type)) return "terminated"; + if (["interrupt", "interrupted", "aborted", "cancelled", "canceled"].includes(type)) return "interrupted"; + if (["error", "failed", "failure"].includes(type)) return "error"; + return "idle"; } From 7d4d9daa59c8dd215352b6fadb47c8ff47deb797 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Sun, 25 Jan 2026 19:11:51 +0800 Subject: [PATCH 14/17] fix: refine timing display and tool labels --- packages/app/src/app/app.tsx | 7 ++- .../app/components/session/message-list.tsx | 50 +++++++++++-------- packages/app/src/app/pages/session.tsx | 20 +------- packages/app/src/app/utils/index.ts | 6 +++ 4 files changed, 39 insertions(+), 44 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 67c0f321..e5c3a28e 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -817,14 +817,13 @@ export default function App() { try { markSessionEndReason(sessionID, "interrupted"); + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); await c.session.abort({ sessionID }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); setError(message); - } finally { - setBusy(false); - setBusyLabel(null); - setBusyStartedAt(null); } } diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index a39380f8..2c764b67 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -219,7 +219,7 @@ export default function MessageList(props: MessageListProps) { const end = timing?.endAt ?? (typeof completed === "number" ? completed : null); if (typeof start !== "number" || typeof end !== "number") return null; const duration = end - start; - if (duration <= 0) return null; + if (duration < 0) return null; const reason = timing?.endReason ?? (typeof completed === "number" ? "completed" : undefined); return { start, end, duration, reason }; }; @@ -247,7 +247,7 @@ export default function MessageList(props: MessageListProps) { if (start === null || end === null) return null; const duration = end - start; - if (duration <= 0) return null; + if (duration < 0) return null; return { duration, reason }; }; @@ -390,15 +390,19 @@ export default function MessageList(props: MessageListProps) { /> - {(timing) => ( -
- - {formatElapsedTime(timing().duration)} - - · {formatReasonLabel(timing().reason!)} - -
- )} + {(timing) => { + const reason = timing().reason; + const reasonLabel = reason && reason !== "completed" ? formatReasonLabel(reason) : null; + return ( +
+ + {formatElapsedTime(timing().duration)} + + · {reasonLabel} + +
+ ); + }}
- {(resolved) => ( -
- - {formatElapsedTime(resolved().duration)} - - · {formatReasonLabel(resolved().reason!)} - -
- )} + {(resolved) => { + const reason = resolved().reason; + const reasonLabel = reason && reason !== "completed" ? formatReasonLabel(reason) : null; + return ( +
+ + {formatElapsedTime(resolved().duration)} + + · {reasonLabel} + +
+ ); + }}
-
+
{copyButton}
diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 59d10b61..de7572b7 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -39,6 +39,7 @@ import Composer from "../components/session/composer"; import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar"; import ContextPanel from "../components/session/context-panel"; import FlyoutItem from "../components/flyout-item"; +import { formatElapsedTime } from "../utils"; export type SessionViewProps = { selectedSessionId: string | null; @@ -454,25 +455,6 @@ export default function SessionView(props: SessionViewProps) { return Math.max(0, runTick() - start); }); - const formatElapsedTime = (ms: number): string => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; - } - if (minutes > 0) { - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; - } - if (seconds > 0) { - return `${seconds}s`; - } - return `${ms}ms`; - }; - const runElapsedLabel = createMemo(() => formatElapsedTime(runElapsedMs())); onMount(() => { diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index 38612a0c..fca16589 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -719,6 +719,12 @@ export function summarizeStep(part: Part): StepSummary { return { title: "Thinking" }; } + if (part.type === "step-start" || part.type === "step-finish") { + return { + title: part.type === "step-start" ? "Step started" : "Step finished", + }; + } + return { title: "Step" }; } From c21ce06e1d792fbc215f8777f3b5a4ce6b7cbf47 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Mon, 2 Feb 2026 13:58:47 +0800 Subject: [PATCH 15/17] fix: resolve typecheck regressions after rebase --- packages/app/src/app/app.tsx | 2 +- packages/app/src/app/pages/settings.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index e5c3a28e..ff3e836c 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -4187,7 +4187,7 @@ export default function App() { })), selectSession: selectSession, messages: activeMessages(), - messageTimings: isDemoMode() ? {} : messageTimings(), + messageTimings: messageTimings(), todos: activeTodos(), busyLabel: busyLabel(), developerMode: developerMode(), diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index 4c230f99..0aa6ef74 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -282,7 +282,8 @@ function OwpenbotSettings(props: { setOwpenbotStatus(latestStatus); } const serverClient = openworkServerClient(); - const useRemote = Boolean(serverClient && props.openworkServerWorkspaceId); + const workspaceId = props.openworkServerWorkspaceId; + const useRemote = Boolean(serverClient && workspaceId); debugOwpenbot("save-token:start", { mode: props.mode ?? "unknown", tauri: isTauriRuntime(), @@ -299,6 +300,7 @@ function OwpenbotSettings(props: { ), }); if (useRemote) { + if (!serverClient || !workspaceId) return; if (props.openworkServerStatus === "disconnected") { setTelegramFeedback( "error", @@ -316,7 +318,7 @@ function OwpenbotSettings(props: { setTelegramFeedback("checking", "Saving token on the host..."); try { await serverClient.setOwpenbotTelegramToken( - props.openworkServerWorkspaceId, + workspaceId, token, latestStatus?.healthPort ?? owpenbotStatus()?.healthPort ?? null, ); From 407ad67f50c76f8ef611c3ed04f30a29c17c0056 Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Mon, 2 Feb 2026 14:06:23 +0800 Subject: [PATCH 16/17] test: stabilize engine path resolution --- packages/desktop/src-tauri/src/engine/doctor.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src-tauri/src/engine/doctor.rs b/packages/desktop/src-tauri/src/engine/doctor.rs index 433e3b6c..4a40e7a5 100644 --- a/packages/desktop/src-tauri/src/engine/doctor.rs +++ b/packages/desktop/src-tauri/src/engine/doctor.rs @@ -2,9 +2,8 @@ use std::ffi::OsStr; use std::path::Path; use crate::engine::paths::{ - resolve_opencode_env_override, - resolve_opencode_executable, - resolve_opencode_executable_without_override, + resolve_opencode_env_override, resolve_opencode_executable, + resolve_opencode_executable_without_override, }; use crate::platform::command_for_program; use crate::utils::truncate_output; @@ -144,6 +143,12 @@ mod tests { std::env::set_var(key, value); Self { key, original } } + + fn unset(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, original } + } } impl Drop for EnvVarGuard { @@ -194,6 +199,8 @@ mod tests { #[test] #[cfg(not(windows))] fn resolve_engine_path_prefers_sidecar() { + let _lock = ENV_LOCK.lock().expect("lock env"); + let _guard = EnvVarGuard::unset("OPENCODE_BIN_PATH"); let dir = unique_temp_dir("engine-path-test"); std::fs::create_dir_all(&dir).expect("create temp dir"); @@ -228,7 +235,9 @@ mod tests { let (resolved, _in_path, notes) = resolve_engine_path(true, None, Some(sidecar_dir.as_path())); assert_eq!(resolved.as_ref(), Some(&override_path)); - assert!(notes.iter().any(|note| note.contains("Using OPENCODE_BIN_PATH"))); + assert!(notes + .iter() + .any(|note| note.contains("Using OPENCODE_BIN_PATH"))); let _ = std::fs::remove_dir_all(&override_dir); let _ = std::fs::remove_dir_all(&sidecar_dir); From cfdbc6ccfc185a3b6cc3090984caa3892da596ec Mon Sep 17 00:00:00 2001 From: Golenspade <2023004079@mails.cust.edu.cn> Date: Mon, 2 Feb 2026 14:13:50 +0800 Subject: [PATCH 17/17] ci: install bun for linux desktop build --- .github/workflows/build-desktop.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 52f5bfc5..a41b06ed 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -26,6 +26,11 @@ jobs: with: version: 10.27.0 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + - name: Install dependencies run: pnpm install --frozen-lockfile