+ {renderStepIcon(summary, status)}
+
+
+ {summary.title}
skill
+
+ {summary.detail}
+
-
- {summary.detail}
-
stepId !== block.id);
const expanded = () => isStepsExpanded(block.id, relatedStepIds);
+ const clusterTiming = () => resolveClusterTiming(block.messageIds);
return (
@@ -260,6 +389,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}>
@@ -378,22 +541,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 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 a3aab7ff..de7572b7 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,
@@ -38,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;
@@ -60,6 +62,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;
@@ -85,6 +88,7 @@ export type SessionViewProps = {
busy: boolean;
prompt: string;
setPrompt: (value: string) => void;
+ cancelRun: () => Promise;
selectedSessionModelLabel: string;
openSessionModelPicker: () => void;
modelVariantLabel: string;
@@ -451,7 +455,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);
@@ -502,29 +506,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,
@@ -1235,6 +1231,7 @@ export default function SessionView(props: SessionViewProps) {
props.cancelRun()}
onDraftChange={handleDraftChange}
commandMatches={commandMatches()}
onRunCommand={handleRunCommand}
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,
);
diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts
index 5a125ea7..09acae42 100644
--- a/packages/app/src/app/types.ts
+++ b/packages/app/src/app/types.ts
@@ -48,6 +48,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 286e2651..fca16589 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(/^\/+/, "");
@@ -389,11 +408,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";
}
@@ -421,7 +456,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[] {
@@ -466,44 +502,226 @@ export function groupMessageParts(parts: Part[], messageId: string): MessageGrou
return groups;
}
-export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: 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",
+ list: "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";
+ 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 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;
-
- // 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 };
+ const input = typeof state.input === "object" && state.input ? state.input : {};
+
+ 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 (toolLower === "todowrite") {
+ return { title: label, icon: isGithubTool ? "github" : "default" };
+ }
+
+ 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}"`;
}
- return { title, isSkill: true, skillName };
}
-
- if (output) {
- const short = output.length > 160 ? `${output.slice(0, 160)}…` : output;
- return { title, detail: short };
+
+ 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 };
+
+ 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 };
+ 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,
};
}
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);