mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
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:
parent
8ae1efbf80
commit
afbb5e71db
21 changed files with 348 additions and 144 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
144
packages/cli/src/ui/hooks/useAwaySummary.test.ts
Normal file
144
packages/cli/src/ui/hooks/useAwaySummary.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
() => () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue