fix(cli): rework session recap rendering and add blur threshold setting (#3482)

* feat(cli): make recap away-threshold configurable

The 5-minute blur threshold was hard-coded. Confirmed from Claude
Code's own binary (v2.1.113) that 5 minutes is their default as well
(and that they shift to 60 minutes when 1h prompt-cache is active) —
so the default stays, but expose it as `general.sessionRecapAway
ThresholdMinutes` for users who briefly alt-tab often and don't want
recaps piling up, or who want to lower it for testing.

Non-positive / unset values fall back to the 5-minute default, so
dropping the key has the same behavior as before.

* fix(core): align recap prompt with Claude Code (1-2 sentences, ≤40 words)

The earlier "exactly one sentence, 80-char cap" was an over-correction
to a single in-the-moment ask. Going back to it: the natural shape of
"current task + next action" is two clauses, and forcing them into a
single sentence either crams them with a semicolon or drops the next
action entirely on complex sessions.

Adopt Claude Code's prompt verbatim (extracted from the v2.1.113
binary): "under 40 words, 1-2 plain sentences, no markdown. Lead with
the overall goal and current task, then the one next action. Skip
root-cause narrative, fix internals, secondary to-dos, and em-dash
tangents." Add a Chinese-budget note (~80 chars) and keep the
<recap>...</recap> wrapping that protects against reasoning-model
preambles leaking into the UI.

The sticky banner already re-measures controls height when the
recap toggles, so a 2-line render lays out cleanly.

Sweep "one-line" out of user-facing copy (settings description,
slash-command description, feature docs, design doc) so the
documentation matches the new shape.

* fix(cli): restore "one-line" in user-facing recap copy

Verified from the Claude Code v2.1.113 binary that the slash-command
description IS literally "Generate a one-line session recap now" even
though the underlying prompt allows 1-2 sentences. Claude Code is
deliberately setting a tighter user expectation than the prompt
guarantees, which keeps the surface feel "glanceable".

Mirror that asymmetry: keep the prompt at 1-2 sentences (the previous
commit) for behavioral parity, but put "one-line" back in the user-
visible copy (slash-command description, settings description, user
docs). Internal design doc keeps the accurate "1-2 sentence" wording.

* fix(cli): render recap inline in history to match Claude Code

Earlier I read the user's complaint that the recap "scrolled away" as
"the recap should be sticky above the input box," and built a sticky
banner accordingly. Disassembly of the Claude Code v2.1.113 binary
shows the actual behavior is the opposite: their away_summary is a
plain `type:"system", subtype:"away_summary"` message dispatched
through the standard message renderer (no Static, no anchor, no
flexbox pinning) — it scrolls with the conversation like every other
system message.

Tear out the sticky-banner machinery so recap matches that:

- Recap is back in the `HistoryItemWithoutId` union and `addItem`'d
  into history (both from `/recap` and from auto-trigger), so it
  serializes into session saves and behaves like every other history
  item — no special clear paths, no resume-wrapper, no layout-effect
  re-measure dance.
- `useAwaySummary` takes `addItem` again instead of a setter callback.
- `AwayRecapMessage` renders the way Claude Code does: a 2-column
  gutter with `※`, then bold "recap: " and italic content, all in
  dim color. Drop the prior `StatusMessage`-shaped layout that fused
  prefix and label into "※ recap:".
- Remove the AppContainer plumbing, the slashCommandProcessor state,
  the UIStateContext fields, the DefaultAppLayout / ScreenReader
  placement blocks, the test-utils mocks, and the noninteractive
  stub. Restore `useResumeCommand.handleResume` to a void return
  since callers no longer need the success boolean.

Sweep the design doc so the architecture diagram, files table, and
hook deps reflect the inline-history flow.

* fix(cli): dedupe back-to-back auto-recaps with no new user turns between

Two consecutive blur cycles, each over the threshold but with no new
user activity in between, would each fire their own auto-recap and
add two near-duplicate entries to history (same task, slightly
different wording from temperature-driven LLM variance). Reported
case: leaving the terminal twice while a /review of one PR was
still on screen produced two recaps both about that same review.

Add a `shouldFireRecap` gate before kicking off the LLM call:

- Need at least 3 user messages in history total (don't fire on a
  near-empty session).
- If a previous away_recap is already in history, need at least 2
  new user messages since that one before another can fire.

Same shape as Claude Code's `Ic1` gate (`Sc1=3`, `Rc1=2`). Read
history through a ref so this isn't in the effect's deps and the
effect doesn't re-run on every message.

* fix(cli): type useResumeCommand.handleResume as Promise<void>

Per gemini review on #3482: the interface declared this as `() => void`
but the implementation is `async` and returns `Promise<void>`. The
mismatch silently lost the chainable promise — tests had to launder
it through `as unknown as Promise<void> | undefined` just to await.

Tighten the interface to `Promise<void>` and drop the cast in the
"closes the dialog immediately" test.

* fix(cli): persist auto-fired recap to chat recording so /resume keeps it

Per yiliang114 review on #3482: the manual `/recap` path persists across
`/resume` because the slash-command processor records every output
history item via `chatRecorder.recordSlashCommand({ phase: 'result',
outputHistoryItems })`, but the auto path called `addItem` directly
and bypassed that recorder. The result was an asymmetry where users
who triggered recap manually saw it after `/resume`, while users whose
recap fired automatically lost it.

Mirror the manual recording from useAwaySummary's `.then` callback —
record only the `result` phase (not invocation, since we don't want
a fake `> /recap` user line replayed) with the away-recap item as the
single output. Wrapped in try/catch because recap is best-effort and
must never surface a failure to the user.

Add useAwaySummary.test.ts covering:
- the recording path is taken on a successful auto-trigger
- the dedup gate (`shouldFireRecap`) suppresses the LLM call entirely,
  including the recording, when no new user turns happened since the
  last recap

* fix(cli): cast recap item via spread to satisfy strict tsc --build

CI's `tsc --build` (stricter than local `tsc --noEmit`) rejected the
direct `item as Record<string, unknown>` cast: HistoryItemAwayRecap's
literal `type: 'away_recap'` field doesn't overlap with `unknown`,
TS2352. Use the `{ ...item } as Record<string, unknown>` spread
pattern that the rest of the codebase (arenaCommand,
slashCommandProcessor's serializer) already uses for the same
SlashCommandRecordPayload field.
This commit is contained in:
Shaojin Wen 2026-04-21 14:39:13 +08:00 committed by GitHub
parent 8ae1efbf80
commit afbb5e71db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 348 additions and 144 deletions

View file

@ -335,7 +335,17 @@ const SETTINGS_SCHEMA = {
// Manual `/recap` works regardless.
default: false,
description:
'Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use /recap to trigger manually regardless of this setting.',
'Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use /recap to trigger manually regardless of this setting.',
showInDialog: true,
},
sessionRecapAwayThresholdMinutes: {
type: 'number',
label: 'Session Recap Away Threshold (minutes)',
category: 'General',
requiresRestart: false,
default: 5,
description:
"How many minutes the terminal must be blurred before an auto-recap fires on the next focus-in. Matches Claude Code's default of 5 minutes; raise if you briefly alt-tab and do not want recaps to pile up.",
showInDialog: true,
},
gitCoAuthor: {

View file

@ -59,8 +59,6 @@ export const createMockCommandContext = (
setBtwItem: vi.fn(),
cancelBtw: vi.fn(),
btwAbortControllerRef: { current: null },
awayRecapItem: null,
setAwayRecapItem: vi.fn(),
isIdleRef: { current: true },
loadHistory: vi.fn(),
toggleVimEnabled: vi.fn(),

View file

@ -570,7 +570,7 @@ export const AppContainer = (props: AppContainerProps) => {
isResumeDialogOpen,
openResumeDialog,
closeResumeDialog,
handleResume: handleResumeInner,
handleResume,
} = useResumeCommand({
config,
historyManager,
@ -658,8 +658,6 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem,
setBtwItem,
cancelBtw,
awayRecapItem,
setAwayRecapItem,
commandContext,
shellConfirmationRequest,
confirmationRequest,
@ -681,22 +679,6 @@ export const AppContainer = (props: AppContainerProps) => {
logger,
);
// Wrap handleResume so the sticky recap from the previous session
// doesn't carry over into the new one. Only clear after the inner
// handler confirms a session was actually loaded — otherwise (no
// session data, missing deps) we'd drop the current session's recap
// for no reason.
const handleResume = useCallback(
async (sessionId: string): Promise<boolean> => {
const switched = await handleResumeInner(sessionId);
if (switched) {
setAwayRecapItem(null);
}
return switched;
},
[handleResumeInner, setAwayRecapItem],
);
// onDebugMessage should log to debug logfile, not update footer debugMessage
const onDebugMessage = useCallback(
(message: string) => {
@ -1248,7 +1230,7 @@ export const AppContainer = (props: AppContainerProps) => {
setControlsHeight(fullFooterMeasurement.height);
}
}
}, [buffer, terminalWidth, terminalHeight, awayRecapItem, btwItem]);
}, [buffer, terminalWidth, terminalHeight, btwItem]);
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
@ -1281,7 +1263,10 @@ export const AppContainer = (props: AppContainerProps) => {
config,
isFocused,
isIdle: streamingState === StreamingState.Idle,
setAwayRecapItem,
addItem: historyManager.addItem,
history: historyManager.history,
awayThresholdMinutes:
settings.merged.general?.sessionRecapAwayThresholdMinutes,
});
// Context file names computation
@ -2101,8 +2086,6 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem,
setBtwItem,
cancelBtw,
awayRecapItem,
setAwayRecapItem,
nightly,
branchName,
sessionStats,
@ -2209,8 +2192,6 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem,
setBtwItem,
cancelBtw,
awayRecapItem,
setAwayRecapItem,
nightly,
branchName,
sessionStats,

View file

@ -65,7 +65,7 @@ export const recapCommand: SlashCommand = {
type: 'away_recap',
text: recap.text,
};
context.ui.setAwayRecapItem(item);
context.ui.addItem(item, Date.now());
return;
}

View file

@ -11,7 +11,6 @@ import type {
HistoryItemWithoutId,
HistoryItem,
HistoryItemBtw,
HistoryItemAwayRecap,
ConfirmationRequest,
} from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
@ -76,10 +75,6 @@ export interface CommandContext {
cancelBtw: () => void;
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
btwAbortControllerRef: MutableRefObject<AbortController | null>;
/** The current away-recap item rendered as a sticky banner above the input box. */
awayRecapItem: HistoryItemAwayRecap | null;
/** Sets the away-recap item independently of the main history. */
setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
/** Ref to whether the agent stream is currently idle (no model turn in flight). */
isIdleRef: MutableRefObject<boolean>;
/**

View file

@ -28,6 +28,7 @@ import {
ErrorMessage,
RetryCountdownMessage,
SuccessMessage,
AwayRecapMessage,
} from './messages/StatusMessages.js';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
@ -285,6 +286,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'memory_saved' && (
<MemorySavedMessage item={itemForDisplay} />
)}
{itemForDisplay.type === 'away_recap' && (
<AwayRecapMessage text={itemForDisplay.text} />
)}
</Box>
);
};

View file

@ -125,11 +125,22 @@ export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
/>
);
// Mirrors Claude Code's away-summary rendering: a `※` prefix in a fixed
// 2-column gutter, then bold "recap: " label and italic content, all
// dim-colored. Rendered as a regular history item so it scrolls with
// the conversation instead of pinning above the input.
export const AwayRecapMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="※ recap:"
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={theme.text.secondary}></Text>
</Box>
<Text wrap="wrap">
<Text color={theme.text.secondary} bold>
recap:{' '}
</Text>
<Text color={theme.text.secondary} italic>
{text}
</Text>
</Text>
</Box>
);

View file

@ -8,7 +8,6 @@ import { createContext, useContext } from 'react';
import type {
HistoryItem,
HistoryItemBtw,
HistoryItemAwayRecap,
ThoughtSummary,
ShellConfirmationRequest,
ConfirmationRequest,
@ -111,8 +110,6 @@ export interface UIState {
btwItem: HistoryItemBtw | null;
setBtwItem: (item: HistoryItemBtw | null) => void;
cancelBtw: () => void;
awayRecapItem: HistoryItemAwayRecap | null;
setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
nightly: boolean;
branchName: string | undefined;
sessionStats: SessionStatsState;

View file

@ -31,7 +31,6 @@ import type {
Message,
HistoryItemWithoutId,
HistoryItemBtw,
HistoryItemAwayRecap,
SlashCommandProcessorResult,
HistoryItem,
ConfirmationRequest,
@ -156,9 +155,6 @@ export const useSlashCommandProcessor = (
const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null);
const btwAbortControllerRef = useRef<AbortController | null>(null);
const [awayRecapItem, setAwayRecapItem] =
useState<HistoryItemAwayRecap | null>(null);
const cancelBtw = useCallback(() => {
btwAbortControllerRef.current?.abort();
btwAbortControllerRef.current = null;
@ -272,7 +268,6 @@ export const useSlashCommandProcessor = (
addItem,
clear: () => {
cancelBtw();
setAwayRecapItem(null);
clearItems();
clearScreen();
refreshStatic();
@ -285,8 +280,6 @@ export const useSlashCommandProcessor = (
setBtwItem,
cancelBtw,
btwAbortControllerRef,
awayRecapItem,
setAwayRecapItem,
isIdleRef,
toggleVimEnabled,
setGeminiMdFileCount,
@ -319,8 +312,6 @@ export const useSlashCommandProcessor = (
btwItem,
setBtwItem,
cancelBtw,
awayRecapItem,
setAwayRecapItem,
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
@ -794,8 +785,6 @@ export const useSlashCommandProcessor = (
btwItem,
setBtwItem,
cancelBtw,
awayRecapItem,
setAwayRecapItem,
commandContext,
shellConfirmationRequest,
confirmationRequest,

View file

@ -0,0 +1,144 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import * as core from '@qwen-code/qwen-code-core';
import { useAwaySummary } from './useAwaySummary.js';
import type { HistoryItem } from '../types.js';
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual<
typeof import('@qwen-code/qwen-code-core')
>('@qwen-code/qwen-code-core');
return {
...actual,
generateSessionRecap: vi.fn(),
};
});
const generateSessionRecapMock = vi.mocked(core.generateSessionRecap);
function makeConfig(recordSlashCommand = vi.fn()) {
return {
getChatRecordingService: vi.fn().mockReturnValue({
recordSlashCommand,
}),
} as unknown as core.Config;
}
function userMsg(text: string): HistoryItem {
return { id: Math.random(), type: 'user', text };
}
const THREE_USER_HISTORY: HistoryItem[] = [
userMsg('one'),
userMsg('two'),
userMsg('three'),
];
beforeEach(() => {
vi.useFakeTimers();
generateSessionRecapMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe('useAwaySummary', () => {
it('records the auto-fired recap to chatRecordingService so it survives /resume', async () => {
const recordSlashCommand = vi.fn();
const config = makeConfig(recordSlashCommand);
const addItem = vi.fn();
generateSessionRecapMock.mockResolvedValue({
text: 'recap text',
modelUsed: 'fast',
});
// Mount blurred to set the away-start timestamp.
const { rerender } = renderHook(
({ isFocused }: { isFocused: boolean }) =>
useAwaySummary({
enabled: true,
config,
isFocused,
isIdle: true,
addItem,
history: THREE_USER_HISTORY,
awayThresholdMinutes: 0.1, // 6 s
}),
{ initialProps: { isFocused: false } },
);
// Advance past the threshold while still blurred.
vi.advanceTimersByTime(7000);
// Focus comes back — should kick off the LLM call.
rerender({ isFocused: true });
// Drain the resolved promise + microtasks.
await vi.waitFor(() => {
expect(addItem).toHaveBeenCalledTimes(1);
});
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'away_recap', text: 'recap text' }),
expect.any(Number),
);
expect(recordSlashCommand).toHaveBeenCalledWith(
expect.objectContaining({
phase: 'result',
rawCommand: '/recap',
outputHistoryItems: [
expect.objectContaining({ type: 'away_recap', text: 'recap text' }),
],
}),
);
});
it('skips the recap when shouldFireRecap returns false (no new user turns since last recap)', async () => {
const recordSlashCommand = vi.fn();
const config = makeConfig(recordSlashCommand);
const addItem = vi.fn();
generateSessionRecapMock.mockResolvedValue({
text: 'should not appear',
modelUsed: 'fast',
});
const historyWithRecentRecap: HistoryItem[] = [
...THREE_USER_HISTORY,
{ id: 999, type: 'away_recap', text: 'previous recap' },
// Fewer than 2 user messages since the recap → gated.
userMsg('only one new turn'),
];
const { rerender } = renderHook(
({ isFocused }: { isFocused: boolean }) =>
useAwaySummary({
enabled: true,
config,
isFocused,
isIdle: true,
addItem,
history: historyWithRecentRecap,
awayThresholdMinutes: 0.1,
}),
{ initialProps: { isFocused: false } },
);
vi.advanceTimersByTime(7000);
rerender({ isFocused: true });
// Give any pending microtasks a chance to flush — they shouldn't.
await Promise.resolve();
await Promise.resolve();
expect(generateSessionRecapMock).not.toHaveBeenCalled();
expect(addItem).not.toHaveBeenCalled();
expect(recordSlashCommand).not.toHaveBeenCalled();
});
});

View file

@ -6,16 +6,62 @@
import { useEffect, useRef } from 'react';
import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core';
import type { HistoryItemAwayRecap } from '../types.js';
import type {
HistoryItem,
HistoryItemAwayRecap,
HistoryItemWithoutId,
} from '../types.js';
const AWAY_THRESHOLD_MS = 5 * 60 * 1000;
const DEFAULT_AWAY_THRESHOLD_MINUTES = 5;
// Dedup thresholds, matching Claude Code's `Sc1`/`Rc1`:
// - need at least MIN_USER_MESSAGES_TO_FIRE user turns total
// - if a recap is already in history, need at least
// MIN_USER_MESSAGES_SINCE_LAST_RECAP new user turns since then before
// another can fire. Prevents back-to-back recaps when the user briefly
// alt-tabs twice without doing any new work in between.
const MIN_USER_MESSAGES_TO_FIRE = 3;
const MIN_USER_MESSAGES_SINCE_LAST_RECAP = 2;
export interface UseAwaySummaryOptions {
enabled: boolean;
config: Config | null;
isFocused: boolean;
isIdle: boolean;
setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void;
addItem: (item: HistoryItemWithoutId, baseTimestamp: number) => number;
/**
* The current chat history. Read at fire time only (via a ref) to apply
* the dedup gate; not added to the effect's deps so it doesn't re-fire
* on every history change.
*/
history: HistoryItem[];
/**
* Minutes the terminal must be blurred before an auto-recap fires on
* the next focus-in. Falsy / non-positive values fall back to the
* 5-minute default (matching Claude Code).
*/
awayThresholdMinutes?: number;
}
/**
* Whether enough new user activity has happened since the last recap to
* justify another one. Mirrors Claude Code's `Ic1` gate.
*/
function shouldFireRecap(history: HistoryItem[]): boolean {
let userMessageCount = 0;
let lastRecapIndex = -1;
for (let i = 0; i < history.length; i++) {
const item = history[i];
if (item.type === 'user') userMessageCount++;
if (item.type === 'away_recap') lastRecapIndex = i;
}
if (userMessageCount < MIN_USER_MESSAGES_TO_FIRE) return false;
if (lastRecapIndex === -1) return true;
let userSinceLast = 0;
for (let i = lastRecapIndex + 1; i < history.length; i++) {
if (history[i].type === 'user') userSinceLast++;
}
return userSinceLast >= MIN_USER_MESSAGES_SINCE_LAST_RECAP;
}
/**
@ -27,7 +73,15 @@ export interface UseAwaySummaryOptions {
* a single back-and-forth produces at most one recap.
*/
export function useAwaySummary(options: UseAwaySummaryOptions): void {
const { enabled, config, isFocused, isIdle, setAwayRecapItem } = options;
const {
enabled,
config,
isFocused,
isIdle,
addItem,
history,
awayThresholdMinutes,
} = options;
const blurredAtRef = useRef<number | null>(null);
const recapPendingRef = useRef(false);
@ -36,6 +90,18 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
const isIdleRef = useRef(isIdle);
isIdleRef.current = isIdle;
// Latest history snapshot, read at fire time only — keeps history out
// of the effect's deps so we don't re-evaluate on every message.
const historyRef = useRef(history);
historyRef.current = history;
const thresholdMs =
(awayThresholdMinutes && awayThresholdMinutes > 0
? awayThresholdMinutes
: DEFAULT_AWAY_THRESHOLD_MINUTES) *
60 *
1000;
useEffect(() => {
if (!enabled || !config) {
inFlightRef.current?.abort();
@ -54,7 +120,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
const blurredAt = blurredAtRef.current;
if (blurredAt === null) return;
if (Date.now() - blurredAt < AWAY_THRESHOLD_MS) {
if (Date.now() - blurredAt < thresholdMs) {
// Brief blur; reset and wait for the next away cycle.
blurredAtRef.current = null;
return;
@ -65,6 +131,14 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
// (with isIdle in the deps) when the streaming turn finishes.
if (!isIdleRef.current) return;
// Skip if the conversation hasn't moved enough since the last recap —
// a brief alt-tab cycle right after a recap shouldn't produce a near-
// duplicate one.
if (!shouldFireRecap(historyRef.current)) {
blurredAtRef.current = null;
return;
}
blurredAtRef.current = null;
recapPendingRef.current = true;
const controller = new AbortController();
@ -78,7 +152,21 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
type: 'away_recap',
text: recap.text,
};
setAwayRecapItem(item);
addItem(item, Date.now());
// Mirror the recording the slash-command processor does for
// manual `/recap`, so the auto-fired recap also survives `/resume`.
// Only record the `result` phase — recording an `invocation`
// would replay a fake `> /recap` user line on resume.
try {
config.getChatRecordingService?.()?.recordSlashCommand({
phase: 'result',
rawCommand: '/recap',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Recap is best-effort — never let a recording failure surface.
}
})
.finally(() => {
if (inFlightRef.current === controller) {
@ -86,7 +174,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
}
recapPendingRef.current = false;
});
}, [enabled, config, isFocused, isIdle, setAwayRecapItem]);
}, [enabled, config, isFocused, isIdle, addItem, thresholdMs]);
useEffect(
() => () => {

View file

@ -167,9 +167,7 @@ describe('useResumeCommand', () => {
act(() => {
// Start resume but do not await it yet — we want to assert the dialog
// closes immediately before the async session load completes.
resumePromise = result.current.handleResume('session-2') as unknown as
| Promise<void>
| undefined;
resumePromise = result.current.handleResume('session-2');
});
expect(result.current.isResumeDialogOpen).toBe(false);

View file

@ -26,12 +26,12 @@ export interface UseResumeCommandResult {
openResumeDialog: () => void;
closeResumeDialog: () => void;
/**
* Resolves to `true` when the target session was actually loaded, or
* `false` when the call short-circuited (missing dependencies or no
* session data found). Callers can use the boolean to gate cleanup
* that should only happen on a successful session switch.
* Async the implementation awaits SessionService and SessionStart hooks.
* Callers that need to chain post-resume work should `await` it; pure
* fire-and-forget callers (the resume dialog's `onSelect`) can ignore the
* promise.
*/
handleResume: (sessionId: string) => Promise<boolean>;
handleResume: (sessionId: string) => Promise<void>;
}
export function useResumeCommand(
@ -50,9 +50,9 @@ export function useResumeCommand(
const { config, historyManager, startNewSession, remount } = options ?? {};
const handleResume = useCallback(
async (sessionId: string): Promise<boolean> => {
async (sessionId: string) => {
if (!config || !historyManager || !startNewSession) {
return false;
return;
}
// Close dialog immediately to prevent input capture during async operations.
@ -63,7 +63,7 @@ export function useResumeCommand(
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return false;
return;
}
// Start new session in UI context.
@ -93,7 +93,6 @@ export function useResumeCommand(
// Refresh terminal UI.
remount?.();
return true;
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
);

View file

@ -12,7 +12,6 @@ import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { BtwMessage } from '../components/messages/BtwMessage.js';
import { AwayRecapMessage } from '../components/messages/StatusMessages.js';
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
@ -70,11 +69,6 @@ export const DefaultAppLayout: React.FC = () => {
</Box>
) : (
<>
{uiState.awayRecapItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<AwayRecapMessage text={uiState.awayRecapItem.text} />
</Box>
)}
{uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage

View file

@ -13,7 +13,6 @@ import { Composer } from '../components/Composer.js';
import { Footer } from '../components/Footer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { BtwMessage } from '../components/messages/BtwMessage.js';
import { AwayRecapMessage } from '../components/messages/StatusMessages.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ScreenReaderAppLayout: React.FC = () => {
@ -36,11 +35,6 @@ export const ScreenReaderAppLayout: React.FC = () => {
</Box>
) : (
<>
{uiState.awayRecapItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<AwayRecapMessage text={uiState.awayRecapItem.text} />
</Box>
)}
{uiState.btwItem && (
<Box marginX={2} width={uiState.mainAreaWidth}>
<BtwMessage

View file

@ -24,8 +24,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
setBtwItem: (_item) => {},
cancelBtw: () => {},
btwAbortControllerRef: { current: null },
awayRecapItem: null,
setAwayRecapItem: (_item) => {},
isIdleRef: { current: true },
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},

View file

@ -390,9 +390,9 @@ export type HistoryItemBtw = HistoryItemBase & {
/**
* Away-summary recap shown when the user returns to the session after a
* period of inactivity (or via /recap). Rendered as a sticky banner above
* the input box (NOT part of the scrolling history), so it is intentionally
* excluded from the HistoryItemWithoutId union.
* period of inactivity (or via /recap). Rendered inline as a regular
* history item (matching Claude Code's away_summary message); scrolls
* with the conversation, no sticky pinning.
*/
export type HistoryItemAwayRecap = HistoryItemBase & {
type: 'away_recap';
@ -484,6 +484,7 @@ export type HistoryItemWithoutId =
| HistoryItemInsightProgress
| HistoryItemBtw
| HistoryItemMemorySaved
| HistoryItemAwayRecap
| HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop
| HistoryItemStopHookSystemMessage