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
+8 -4
View File
@@ -4,13 +4,14 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [1.0.1]
A batch of loop and ergonomics work landed after 1.0.0. Version remains `1.0.0` in
`src/version.ts`; these are folded into the next tagged release.
### Added ### Added
- **Rendered, resumed history.** A session restored with `-r`/`-c` or `/resume` now shows
its saved conversation as real transcript lines instead of a blank prompt, converting the
stored wire messages (user, assistant, tool calls and their results) into the same view the
live loop paints.
- **`/undo` and `/redo`.** Every prompt snapshots the files on disk first (capped near the last - **`/undo` and `/redo`.** Every prompt snapshots the files on disk first (capped near the last
100), and `/undo` restores files, trims the conversation, or both. `/redo` reverses it. A 100), and `/undo` restores files, trims the conversation, or both. `/redo` reverses it. A
`bash` command's side effects are not files and cannot be rolled back, which is stated in the `bash` command's side effects are not files and cannot be rolled back, which is stated in the
@@ -30,6 +31,9 @@ A batch of loop and ergonomics work landed after 1.0.0. Version remains `1.0.0`
### Fixed ### Fixed
- A resumed session rendered an empty transcript. Loading a saved session populated the wire
messages but never rebuilt the on-screen history, so after `-r`/`-c` or `/resume` the talk
was blank even though the session data was there.
- The walk behind `@file` completion refreshed only once per session; it now re-walks on a slow - The walk behind `@file` completion refreshed only once per session; it now re-walks on a slow
cooldown so a file created after the first `@` shows up within a short window. cooldown so a file created after the first `@` shows up within a short window.
- A handful of plugin write tools were mutating but not gated by the permission defaults; the - A handful of plugin write tools were mutating but not gated by the permission defaults; the
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shiro-neko", "name": "shiro-neko",
"version": "1.0.0", "version": "1.0.1",
"type": "module", "type": "module",
"private": true, "private": true,
"bin": { "bin": {
+13 -3
View File
@@ -36,7 +36,7 @@ import { CommandMenu, InstallConfirm, Picker } from './Pickers';
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies'; import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
import { PromptInput } from './PromptInput'; import { PromptInput } from './PromptInput';
import { accent, glyph } from './theme'; 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 { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent };
export type { ApprovalBridge, NoticeBus, SubagentBus }; export type { ApprovalBridge, NoticeBus, SubagentBus };
@@ -128,7 +128,11 @@ export function App({
stdout.off('resize', onResize); stdout.off('resize', onResize);
}; };
}, [stdout]); }, [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 [draft, setDraft] = useState('');
const [live, setLive] = useState(''); const [live, setLive] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -684,7 +688,13 @@ export function App({
push({ kind: 'user', text: chosen.trim() }); push({ kind: 'user', text: chosen.trim() });
try { try {
const msg = await hooks.resumeSession(action.id); 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 }); push({ kind: 'info', text: msg });
} catch (e) { } catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(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; 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. */ /** A task list as markdown, for the `/todos` panel. */
export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) => export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) =>
todos.length > 0 todos.length > 0
+1 -1
View File
@@ -5,7 +5,7 @@
* fails inside the shipped binary. A constant is compiled in and always correct. * 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. * `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. */ /** What `--version` prints: enough to identify a build from a bug report. */
export function versionLine(): string { export function versionLine(): string {
+93
View File
@@ -0,0 +1,93 @@
import { expect, test } from 'bun:test';
import { historyFromMessages } from '../src/ui/transcript';
import type { Line } from '../src/ui/transcript';
const kind = (lines: Line[]) => lines.map((l) => l.kind);
test('historyFromMessages maps a saved conversation into transcript lines', () => {
const lines = historyFromMessages([
{ role: 'user', content: 'fix the pagination test' },
{ role: 'assistant', content: 'Looking at the suite' },
{
role: 'assistant',
content: [
{ type: 'text', text: 'Found it.' },
{
type: 'tool-call',
toolCallId: 't1',
toolName: 'edit_file',
input: { path: 'test/PageTest.php', oldString: 'a', newString: 'b' },
},
],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 't1', toolName: 'edit_file', output: { type: 'text', value: 'Replaced 1 occurrence' } }],
},
]);
// user + assistant text + assistant text part + tool call + existing call line
expect(kind(lines)).toEqual(['user', 'assistant', 'assistant', 'tool']);
const tool = lines.at(-1)!;
expect(tool.kind).toBe('tool');
if (tool.kind === 'tool') {
expect(tool.result).toBe('Replaced 1 occurrence');
expect(tool.ok).toBe(true);
}
});
test('historyFromMessages unwraps SDK-wrapped and json-wrapped tool outputs', () => {
const lines = historyFromMessages([
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'g1', toolName: 'grep', input: { pattern: 'TODO' } }],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'g1', toolName: 'grep', output: { type: 'text', value: 'src/a.ts:1: TODO' } }],
},
]);
const line = lines[0]!;
expect(line.kind).toBe('tool');
if (line.kind === 'tool') expect(line.result).toBe('1 hit');
});
test('historyFromMessages marks a tool-error call as failed', () => {
const lines = historyFromMessages([
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'b1', toolName: 'bash', input: { command: 'false' } }],
},
{
role: 'tool',
content: [{ type: 'tool-error', toolCallId: 'b1', toolName: 'bash', output: { type: 'text', value: 'exit 1' } }],
},
]);
const line = lines[0]!;
expect(line.kind).toBe('tool');
if (line.kind === 'tool') {
expect(line.ok).toBe(false);
expect(line.result).toBe('exit 1');
}
});
test('historyFromMessages drops a tool result with no matching call (pruned lead-in)', () => {
const lines = historyFromMessages([
{ role: 'user', content: 'continue' },
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'gone', toolName: 'grep', output: { type: 'text', value: 'x' } }],
},
]);
// Only the user line remains; the orphaned result is not floated.
expect(kind(lines)).toEqual(['user']);
});
test('historyFromMessages skips system and empty messages', () => {
const lines = historyFromMessages([
{ role: 'system', content: 'You are an agent.' },
{ role: 'user', content: '' },
{ role: 'assistant', content: 'ok' },
]);
expect(kind(lines)).toEqual(['assistant']);
});