-
- {part.type === "tool" ? : }
-
-
-
{summary.title}
-
- {summary.detail}
-
-
-
-
-
+
+ {renderStepIcon(summary, status)}
+ {summary.title}
+
+ {summary.detail}
+
);
}}
@@ -218,6 +325,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 (
@@ -244,6 +352,21 @@ export default function MessageList(props: MessageListProps) {
class={`transition-transform ${expanded() ? "rotate-180" : ""}`.trim()}
/>
+
+ {(timing) => {
+ const reason = timing().reason;
+ const reasonLabel = reason && reason !== "completed" ? formatReasonLabel(reason) : null;
+ return (
+
+
+ {formatElapsedTime(timing().duration)}
+
+ · {reasonLabel}
+
+
+ );
+ }}
+
{
+ const text = block.renderableParts
+ .map((part) => ("text" in part ? (part as any).text : ""))
+ .join("\n");
+ handleCopy(text, block.messageId);
+ }}
+ >
+
}>
+
+
+
+ );
+
return (
+
{copyButton}
0}>
@@ -362,22 +504,35 @@ export default function MessageList(props: MessageListProps) {
)}
-
-
-
+
+ {(() => {
+ const timing = () => resolveMessageTiming(block.message);
+
+ return (
+
+
+ {(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/context/session.ts b/packages/app/src/app/context/session.ts
index 4cdfb76..33f1d4b 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,
@@ -36,6 +38,7 @@ type StoreState = {
sessionStatus: Record;
messages: Record;
parts: Record;
+ messageTimings: Record;
todos: Record;
pendingPermissions: PendingPermission[];
events: OpencodeEvent[];
@@ -102,6 +105,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;
@@ -119,12 +151,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 opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i;
@@ -216,6 +250,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();
@@ -229,6 +264,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 [];
@@ -461,6 +527,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);
+ }
}
}
}
@@ -471,6 +542,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");
}
}
}
@@ -496,6 +569,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,
+ }));
+ }
}
}
}
@@ -508,6 +589,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;
+ });
}
}
}
@@ -518,6 +604,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) => {
@@ -540,6 +627,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);
@@ -683,6 +787,7 @@ export function createSessionStore(options: {
selectedSession,
selectedSessionStatus,
messages,
+ messageTimings,
todos,
pendingPermissions,
permissionReplyBusy,
@@ -693,6 +798,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 5c2a063..554ed16 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,
PendingPermission,
SkillCard,
@@ -35,6 +36,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;
@@ -51,6 +53,7 @@ export type SessionViewProps = {
sessions: Array<{ id: string; title: string; slug?: string | null }>;
selectSession: (sessionId: string) => Promise | void;
messages: MessageWithParts[];
+ messageTimings: Record;
todos: TodoItem[];
busyLabel: string | null;
developerMode: boolean;
@@ -73,6 +76,7 @@ export type SessionViewProps = {
busy: boolean;
prompt: string;
setPrompt: (value: string) => void;
+ cancelRun: () => Promise;
selectedSessionModelLabel: string;
openSessionModelPicker: () => void;
modelVariantLabel: string;
@@ -360,7 +364,7 @@ export default function SessionView(props: SessionViewProps) {
return Math.max(0, runTick() - start);
});
- const runElapsedLabel = createMemo(() => `${Math.round(runElapsedMs()).toLocaleString()}ms`);
+ const runElapsedLabel = createMemo(() => formatElapsedTime(runElapsedMs()));
onMount(() => {
setTimeout(() => setIsInitialLoad(false), 2000);
@@ -402,23 +406,15 @@ 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) {
- messagesEndEl?.scrollIntoView({ behavior: "smooth" });
- }
- },
- ),
- );
+ const [prevMessageCount, setPrevMessageCount] = createSignal(0);
+ createEffect(() => {
+ const currentCount = props.messages.length;
+ const prev = prevMessageCount();
+ if (currentCount > prev) {
+ messagesEndEl?.scrollIntoView({ behavior: "smooth" });
+ }
+ setPrevMessageCount(currentCount);
+ });
const triggerFlyout = (
sourceEl: Element | null,
@@ -1047,6 +1043,7 @@ export default function SessionView(props: SessionViewProps) {
props.cancelRun()}
onDraftChange={handleDraftChange}
commandMatches={commandMatches()}
onRunCommand={handleRunCommand}
diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts
index eee0ecd..5ed115c 100644
--- a/packages/app/src/app/types.ts
+++ b/packages/app/src/app/types.ts
@@ -40,6 +40,15 @@ export type MessageWithParts = {
parts: Part[];
};
+export type MessageEndReason = "completed" | "interrupted" | "terminated" | "error";
+
+export type MessageTiming = {
+ startAt?: number;
+ lastTokenAt?: number;
+ endAt?: number;
+ endReason?: MessageEndReason;
+};
+
export type MessageGroup =
| { kind: "text"; part: Part }
| { kind: "steps"; id: string; parts: Part[] };
diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts
index f685202..0b7e205 100644
--- a/packages/app/src/app/utils/index.ts
+++ b/packages/app/src/app/utils/index.ts
@@ -227,6 +227,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(/^\/+/, "");
@@ -379,11 +398,27 @@ export function removePart(list: MessageWithParts[], messageID: string, partID:
}
export function normalizeSessionStatus(status: unknown) {
- if (!status || typeof status !== "object") return "idle";
- const record = status as Record;
- 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";
}
@@ -411,7 +446,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[] {
@@ -462,34 +498,203 @@ export function groupMessageParts(parts: Part[], messageId: string): MessageGrou
return groups;
}
-export function summarizeStep(part: Part): { title: string; detail?: string } {
+const TOOL_LABELS: Record = {
+ 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",
+ skill: "Skill",
+ todowrite: "Todo",
+};
+
+// 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("/");
+}
+
+// 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;
+}
+
+// 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";
+};
+
+export function summarizeStep(part: Part): StepSummary {
if (part.type === "tool") {
const record = part as any;
const toolName = record.tool ? String(record.tool) : "Tool";
+ const toolLower = toolName.toLowerCase();
+ const label = TOOL_LABELS[toolLower] ?? 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;
- if (output) {
- const short = output.length > 160 ? `${output.slice(0, 160)}…` : output;
- return { title, detail: short };
+ const input = typeof state.input === "object" && state.input ? state.input : {};
+
+ // Determine if this is a GitHub-related tool
+ 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 ")));
+
+ // Some tools don't need detail
+ const noDetailTools = ["todowrite"];
+ if (noDetailTools.includes(toolLower)) {
+ return { title: label, icon: isGithubTool ? "github" : "default" };
+ }
+
+ // Extract detail based on tool type
+ let detail: string | null = null;
+
+ // Read file
+ if (toolLower === "read") {
+ detail = formatReadInfo(input);
+ }
+ // List directory
+ else if (toolLower === "ls" || toolLower === "list") {
+ detail = formatListInfo(input);
+ }
+ // Search (grep/glob)
+ else if (["grep", "glob", "find"].includes(toolLower)) {
+ detail = formatSearchInfo(toolLower, input);
}
- return { title };
+ // Command/Execute
+ else if (["bash", "execute", "shell"].includes(toolLower)) {
+ detail = formatCommandInfo(input);
+ }
+ // Edit/Write/Create - show file path
+ 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());
+ }
+ }
+ // Fetch/WebSearch - show URL or query
+ 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}"`;
+ }
+ }
+
+ // Fallback to state.title if no detail extracted
+ if (!detail && 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();
+ detail = title.length > 60 ? `${title.slice(0, 60)}…` : title;
+ }
+
+ return {
+ title: label,
+ detail: detail ?? undefined,
+ icon: isGithubTool ? "github" : "default"
+ };
}
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 };
- }
-
- 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,
- };
+ return { title: "Thinking" };
}
return { title: "Step" };