release 1.0.1: fix resume showing a blank transcript

A resumed session loaded its wire messages but never rebuilt the on-screen
history, so -r/-c and /resume rendered a blank transcript. Convert the stored
messages back into transcript lines and seed the panel with them at mount and
after /resume.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-17 19:26:42 +07:00
parent 052423556a
commit 8eebc0f4d6
6 changed files with 212 additions and 9 deletions
+13 -3
View File
@@ -36,7 +36,7 @@ import { CommandMenu, InstallConfirm, Picker } from './Pickers';
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
import { PromptInput } from './PromptInput';
import { accent, glyph } from './theme';
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
import { historyFromMessages, nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent };
export type { ApprovalBridge, NoticeBus, SubagentBus };
@@ -128,7 +128,11 @@ export function App({
stdout.off('resize', onResize);
};
}, [stdout]);
const [history, setHistory] = useState<Line[]>([]);
// Seeded from the resumed history: a session loaded with -r/-c should show its
// saved conversation rather than a blank transcript. Only read once, at mount.
const [history, setHistory] = useState<Line[]>(() =>
session.messages.length === 0 ? [] : historyFromMessages(session.messages as { role?: string; content?: unknown }[]),
);
const [draft, setDraft] = useState('');
const [live, setLive] = useState('');
const [busy, setBusy] = useState(false);
@@ -684,7 +688,13 @@ export function App({
push({ kind: 'user', text: chosen.trim() });
try {
const msg = await hooks.resumeSession(action.id);
setHistory([]);
// Reflect the freshly loaded history: session.messages now holds the
// restored wire messages, and the transcript must show them again.
setHistory(
session.messages.length === 0
? []
: historyFromMessages(session.messages as { role?: string; content?: unknown }[]),
);
push({ kind: 'info', text: msg });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
+96
View File
@@ -197,6 +197,102 @@ export function withResult(lines: Line[], name: string, result: string, ok: bool
return lines;
}
/**
* The tool output that was stored on a `tool-result`. The SDK json-wraps a
* string return as `{ type: 'text', value }`, so a restored message needs the
* same unwrap the live stream already produced at save time.
*/
function toolResultText(output: unknown): string {
if (typeof output === 'string') return output;
if (
output !== null &&
typeof output === 'object' &&
'value' in output &&
typeof (output as { value: unknown }).value === 'string'
) {
return (output as { value: string }).value;
}
try {
return JSON.stringify(output);
} catch {
return String(output);
}
}
type StoredPart = {
type?: string;
toolName?: string;
input?: unknown;
output?: unknown;
text?: unknown;
};
/**
* The transcript lines a saved `ModelMessage[]` becomes, so a resumed session
* renders its history instead of starting blank.
*
* Mirrors how the live loop paints: user strings as user lines, assistant text
* as an assistant line, assistant `tool-call` parts as tool lines, and each
* `role: 'tool'` result attached to the newest unanswered call of that name just
* like `withResult` does. A result with no matching call (a pruned lead-in) is
* dropped rather than left floating.
*/
export function historyFromMessages(messages: readonly { role?: string; content?: unknown }[]): Line[] {
const lines: Line[] = [];
const textOf = (content: unknown): string =>
typeof content === 'string'
? content
: Array.isArray(content)
? (content as StoredPart[]).filter((p) => p.type === 'text' && typeof p.text === 'string').map((p) => p.text as string).join('')
: '';
const toolPartsOf = (content: unknown): StoredPart[] =>
Array.isArray(content) ? (content as StoredPart[]).filter((p) => p.type === 'tool-call') : [];
for (const m of messages) {
switch (m.role) {
case 'user': {
const text = textOf(m.content).trim();
if (text) lines.push({ key: nextKey(), kind: 'user', text });
break;
}
case 'assistant': {
const text = textOf(m.content).trim();
if (text) lines.push({ key: nextKey(), kind: 'assistant', text });
for (const p of toolPartsOf(m.content)) {
const name = p.toolName ?? '';
if (!name) continue;
lines.push({ key: nextKey(), kind: 'tool', name, detail: toolDetail(name, p.input), ok: true });
}
break;
}
case 'tool': {
const parts = Array.isArray(m.content) ? (m.content as StoredPart[]) : [];
for (const p of parts) {
if (p.type !== 'tool-result' && p.type !== 'tool-error') continue;
const name = p.toolName ?? '';
const result =
p.type === 'tool-error'
? toolResultText(p.output) || 'tool failed'
: resultSummary(name, toolResultText(p.output));
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (line.kind !== 'tool' || line.name !== name || line.result !== undefined) continue;
lines[i] = { ...line, result, ok: p.type !== 'tool-error' };
break;
}
}
break;
}
default:
break;
}
}
return lines;
}
/** A task list as markdown, for the `/todos` panel. */
export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) =>
todos.length > 0
+1 -1
View File
@@ -5,7 +5,7 @@
* fails inside the shipped binary. A constant is compiled in and always correct.
* `scripts/release.ts` checks it against the release tag so the two cannot drift.
*/
export const VERSION = '1.0.0';
export const VERSION = '1.0.1';
/** What `--version` prints: enough to identify a build from a bug report. */
export function versionLine(): string {