feat(cli): add persistent history collapse on resume with refined commands (#4085)

* feat(cli): add --quiet-restore flag to suppress history output on session resume

* fix: preserve history state for /rewind while suppressing rendering

* refactor: model quiet-restore as display policy with shared utilities

* refactor: replace --quiet-restore with /history collapse|expand slash command

* fix: persist history collapse state as user setting

* fix(cli): address maintainer feedback on history collapse persistence and i18n

* test(cli): fix TypeScript compilation errors in historyCommand tests

* fix(cli): address maintainer review feedback on history collapse

* test: fix act() warning in slashCommandProcessor.test.ts

* fix: make applyCollapsePolicyAndSummary pure to avoid React batching bug

* chore: revert unrelated changes to lockfile and NOTICES.txt

* test: verify isRealUserTurn handles suppressOnRestore items correctly

* wip(cli): preserve local history review fixes before redesign

* feat(cli): refine history resume collapse commands

* fix(cli): address maintainer review feedback on history collapse

* test(cli): cover cold-boot collapsed resume

* fix(cli): address reviewer feedback on history collapse

* fix(cli): resolve rebase conflicts and missing imports

* fix(cli): strip suppressOnRestore in handleRewindConfirm

* fix(i18n): add Chinese translations for history collapse commands

* fix: address wenshao review comments on PR #4085

- Restore restoreGoalFromHistory call in cold-boot resume path
- Extract stripSuppressOnRestore to shared utility in resumeHistoryUtils
- Add comment explaining historyRef pattern in slashCommandProcessor
- Use historyCommand.name constant instead of string literal
- Add missing i18n translation for collapse summary message
- Fix pluralization in createHistoryCollapseSummaryItem

* fix(i18n): add missing English translations for history collapse commands

* fix: address wenshao follow-up review comments

- Filter out collapse-summary items in rewind path (AppContainer.tsx)
- Show info messages for collapse-on-resume/expand-on-resume commands (slashCommandProcessor.ts)
- Use visibleHistory instead of uiState.history in summaryByCallId useMemo (MainContent.tsx)
- Remove dead hasHistoryManager guard and optional chaining (useResumeCommand.ts)

* fix: restore optional chaining for remount in useResumeCommand

* test: update slashCommandProcessor tests for history command feedback changes

* chore: remove generated artifact files from branch

- Remove .learnings/LEARNINGS.md (local workflow artifact)
- Remove .pr-body.md (PR description draft)
- Remove build_output.log (build log)
- Remove vscode_test_output.log (test output log)

These files are unrelated to the history-collapse feature and were causing
git diff --check whitespace errors.

* fix: address wenshao review comments on PR #4085

- Remove stray [!tip] file from repo root
- Add selfManaged flag to MessageActionReturn for explicit UI feedback control
- Fix expand-now to return load_history type instead of calling loadHistory directly
- Replace hardcoded isSelfManaged path check with result.selfManaged in processor
- Fix MainContent merge detection to use visibleHistory.length (avoid flicker)
- Refactor applyCollapsePolicyAndSummary to not mutate input array
- Extract expandCollapsedHistory shared helper
- Restore dialog:memory test in slashCommandProcessor.test.ts
- Add stripSuppressOnRestore dedicated tests
- Add visibleHistory filtering test in MainContent.test.tsx
- Update historyCommand tests for new load_history return type
- Fix eslint errors: remove unused imports and fix dependency array

* fix: address wenshao review comments on PR #4085

- Remove stray [!tip] file from repo root
- Add selfManaged flag to MessageActionReturn for explicit UI feedback control
- Fix expand-now to return load_history type instead of calling loadHistory directly
- Replace hardcoded isSelfManaged path check with result.selfManaged in processor
- Fix MainContent merge detection to use visibleHistory.length (avoid flicker)
- Refactor applyCollapsePolicyAndSummary to not mutate input array
- Extract expandCollapsedHistory shared helper
- Restore dialog:memory test in slashCommandProcessor.test.ts
- Add stripSuppressOnRestore dedicated tests
- Add visibleHistory filtering test in MainContent.test.tsx
- Update historyCommand tests for new load_history return type
- Fix eslint errors: remove unused imports and fix dependency array
- Fix TypeScript errors: add useEffect import, fix display.kind type assertions

* fix: add braces around if statement body (eslint curly rule)

Fixes lint error in editorGroupUtils.ts:
- Expected { after 'if' condition on line 32

* fix: address wenshao follow-up review comments on PR #4085

- Revert expand-now to use loadHistory/refreshStatic directly (no load_history return)
- Remove dead selfManaged flag from MessageActionReturn and processor
- Fix visibleHistory filter to also exclude collapse-summary items
- Simplify applyCollapsePolicyAndSummary (return rawItems when !collapseOnResume)
- Fix test assertion shapes (content→text, timestamp as separate arg)
- Add mockClient for expand-now test
- Update historyCommand tests for new behavior
- Add expandCollapsedHistory dedicated tests
- Fix MainContent filtering test to use historyItemDisplayPropsSpy

* fix: address wenshao latest review comments on PR #4085

- Fix visibleHistory filter: remove collapse-summary exclusion (summary should render when all items suppressed)
- Update MainContent test: assert summary item IS rendered alongside unsuppressed items
- Remove dead selfManaged flag from MessageActionReturn type
- Remove dead selfManaged check from slashCommandProcessor.ts
- Add remount?.() to useResumeCommand error path
- Remove unused 'History expanded.' translation key from en.js, zh.js, zh-TW.js
- Fix expand-now test: mock action to return undefined (matching real behavior)

* fix: address wenshao latest review comments on PR #4085

- Add missing i18n translations to ca.js, de.js, fr.js, ja.js, pt.js, ru.js
- Simplify applyResumeDisplayPolicy: remove dead options parameter
- Fix expand-now test: mock action to return undefined (matches real behavior)
- Revert unrelated changes to package-lock.json and editorGroupUtils.ts

* fix: address wenshao latest review comments

- Add openDiffDialog to createMockActions() in slashCommandProcessor.test.ts
- Add sentToModel: false to user message assertions in slashCommandProcessor.test.ts
- Fix useResumeCommand.test.ts mock: spread original @qwen-code/qwen-code-core exports to include createDebugLogger
- Remove dead optional chaining (addItem?., clearItems?., loadHistory?.) in useResumeCommand.ts
- Simplify if (!config || !startNewSession) to if (!config) since startNewSession is required
- Fix truncatedCount off-by-one in AppContainer.tsx rewind path: exclude collapse-summary from effective length
- Apply collapse policy (applyCollapsePolicyAndSummary) in useBranchCommand.ts for /branch
- Add settings to UseBranchCommandOptions with proper useCallback dependency

* fix: add missing mockUpdateItem arg to test renderHook calls, remove stray file

- Add mockUpdateItem (17th arg) to resume-direct and memory dialog test calls
- Remove accidentally committed packages/core/.qwen/computer-use/installed.json
- Add .qwen/computer-use/installed.json to .gitignore

* fix: address review comments (type, gitignore, test, split-brain)

1. UIActionsContext: handleResume return type → Promise<void>
2. .gitignore: split corrupted merged line into .codegraph + .qwen/...
3. useBranchCommand.test: add settings to makeOptions(), add collapseOnResume test
4. useResumeCommand: reorder core-before-UI with rollback (matches branch pattern)
5. useResumeCommand.test: add getSessionId to mocks, add rollback test

* fix: complete history collapse translations in 6 locale files

- Added missing translation text for 'History collapsed' message
- Fixed syntax errors in de, fr, ja, pt, ru, zh-TW locale files

* fix: add missing history parameter in slashCommandProcessor test

- Fixed parameter order in 'should skip reload when consumeSlashReloadSuppression' test
- Added missing empty array for history parameter
- All 58 tests now pass

* merge: resolve conflicts with upstream main

- docs: keep ui.history.collapseOnResume setting, adopt upstream showCitations default
- fix: update rewindRecording call to include file history snapshots parameter

* fix(cli): repair history collapse CI failures

---------

Co-authored-by: qqqys <qys177@gmail.com>
This commit is contained in:
Gove 2026-06-20 03:22:48 +08:00 committed by GitHub
parent 3b286b2e04
commit 0ba245ea3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1535 additions and 110 deletions

1
.gitignore vendored
View file

@ -107,3 +107,4 @@ tmp/
# code graph skills
.venv
.codegraph
.qwen/computer-use/installed.json

View file

@ -111,6 +111,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` |
| `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`; on macOS the terminal must send Option as Meta. See [Markdown Rendering](../features/markdown-rendering). | `"render"` |
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` |
| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` |
| `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` |
| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` |
| `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |

View file

@ -712,6 +712,27 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
history: {
type: 'object',
label: 'History',
category: 'UI',
requiresRestart: false,
default: {},
description: 'History display settings.',
showInDialog: false,
properties: {
collapseOnResume: {
type: 'boolean',
label: 'Collapse On Resume',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Whether to collapse history by default when resuming a session.',
showInDialog: false,
},
},
},
showLineNumbers: {
type: 'boolean',
label: 'Show Line Numbers in Code',

View file

@ -2035,4 +2035,24 @@ export default {
in: 'ent.',
out: 'sort.',
'In/Out': 'Ent/Sort',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Establir la història per reduir-se per defecte en reprendre una sessió',
'Set history to expand by default when resuming a session':
"Establir la història per expandir-se per defecte en reprendre una sessió",
'Expand the currently collapsed history transcript':
'Expandir la transcripció de la història actualment reduïda',
'Control history display preferences and visibility':
'Controlar les preferències de visualització de la història i la visibilitat',
'History will be collapsed by default for future resumed sessions.':
'La història es reduirà per defecte per a futures sessions represes.',
'History will be expanded by default for future resumed sessions.':
"La història s'expandirà per defecte per a futures sessions represes.",
'History is already expanded in this session.':
'La història ja està expandida en aquesta sessió.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Ús: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'Història reduïda: {{n}} missatges ocults. Utilitzeu /history expand-now per mostrar.',
};

View file

@ -2035,6 +2035,26 @@ export default {
'Weitere Dream-Läufe können als gesperrt übersprungen werden, bis der nächste Stale-Sweep der Sitzung die Datei bereinigt.',
"The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.":
'Das Scheduler-Gate hat den Zeitstempel dieses Dream-Laufs nicht gesehen; der nächste Dream-Zyklus kann früher als üblich erneut starten.',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'Geschichte eingeklappt: {{n}} Nachrichten ausgeblendet. Verwenden Sie /history expand-now zum Anzeigen.',
// === Same-as-English optimization ===
'Agents:': 'Agenten:',
Prompt: 'Eingabe',

View file

@ -1004,6 +1004,26 @@ export default {
'Terminal "{{terminal}}" is not supported yet.':
'Terminal "{{terminal}}" is not supported yet.',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.',
// ============================================================================
// Commands - Language
// ============================================================================

View file

@ -2034,6 +2034,26 @@ export default {
'% context used': '% de contexte utilisé',
'Context exceeds limit! Use /compress or /clear to reduce.':
'Le contexte dépasse la limite ! Utilisez /compress ou /clear pour le réduire.',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'Historique réduit : {{n}} messages masqués. Utilisez /history expand-now pour afficher.',
// === Same-as-English optimization ===
Auth: 'Authentification',
Auto: 'Automatique',

View file

@ -1799,6 +1799,26 @@ export default {
'次回のセッション期限切れクリーンアップでファイルが削除されるまで、以降の dream はロック中としてスキップされる可能性があります。',
"The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.":
'スケジューラーゲートがこの dream のタイムスタンプを認識しませんでした。次の dream サイクルは通常より早く再実行される可能性があります。',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'履歴を折りたたみました:{{n}} 件のメッセージが非表示です。/history expand-now で表示します。',
// === Same-as-English optimization ===
' (not in model registry)': '(モデルレジストリにありません)',
'Attribution: commit': 'コミットの帰属表示',

View file

@ -2021,6 +2021,26 @@ export default {
'Dreams posteriores podem ser ignorados como bloqueados até que a próxima varredura de sessões obsoletas limpe o arquivo.',
"The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.":
'O gate do agendador não viu o timestamp deste dream; o próximo ciclo de dream pode disparar novamente antes do normal.',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'Histórico recolhido: {{n}} mensagens ocultas. Use /history expand-now para mostrar.',
// === Same-as-English optimization ===
'(workspace)': '(espaço de trabalho)',
'Ref:': 'Referência:',

View file

@ -2012,6 +2012,26 @@ export default {
'Последующие dream-запуски могут пропускаться как заблокированные, пока следующая очистка устаревших сессий не удалит файл.',
"The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.":
'Планировщик не увидел временную метку этого dream-запуска; следующий цикл dream может запуститься раньше обычного.',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'Set history to collapse by default when resuming a session',
'Set history to expand by default when resuming a session':
'Set history to expand by default when resuming a session',
'Expand the currently collapsed history transcript':
'Expand the currently collapsed history transcript',
'Control history display preferences and visibility':
'Control history display preferences and visibility',
'History will be collapsed by default for future resumed sessions.':
'History will be collapsed by default for future resumed sessions.',
'History will be expanded by default for future resumed sessions.':
'History will be expanded by default for future resumed sessions.',
'History is already expanded in this session.':
'History is already expanded in this session.',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'История свёрнута: {{n}} сообщений скрыто. Используйте /history expand-now для отображения.',
// === Same-as-English optimization ===
' (not in model registry)': ' (не в реестре моделей)',
'start server': 'запустить сервер',

View file

@ -1815,6 +1815,24 @@ export default {
'\u2191 tabs \u00B7 r to cycle dates \u00B7 esc to close':
'\u2191 tab 切換標籤 \u00B7 r 切換時間範圍 \u00B7 esc 關閉',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'恢復會話時預設摺疊歷史記錄',
'Set history to expand by default when resuming a session':
'恢復會話時預設展開歷史記錄',
'Expand the currently collapsed history transcript': '展開當前摺疊的歷史記錄',
'Control history display preferences and visibility':
'控制歷史記錄顯示偏好和可見性',
'History will be collapsed by default for future resumed sessions.':
'未來恢復的會話將預設摺疊歷史記錄。',
'History will be expanded by default for future resumed sessions.':
'未來恢復的會話將預設展開歷史記錄。',
'History is already expanded in this session.': '當前會話的歷史記錄已展開。',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'用法:/history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'歷史記錄已摺疊:{{n}} 條訊息已隱藏。使用 /history expand-now 展開。',
// === Same-as-English optimization ===
' (not in model registry)': '(不在模型註冊表中)',
'start server': '啟動伺服器',

View file

@ -2014,6 +2014,24 @@ export default {
in: '输入',
out: '输出',
// === History collapse/expand commands ===
'Set history to collapse by default when resuming a session':
'恢复会话时默认折叠历史记录',
'Set history to expand by default when resuming a session':
'恢复会话时默认展开历史记录',
'Expand the currently collapsed history transcript': '展开当前折叠的历史记录',
'Control history display preferences and visibility':
'控制历史记录显示偏好和可见性',
'History will be collapsed by default for future resumed sessions.':
'未来恢复的会话将默认折叠历史记录。',
'History will be expanded by default for future resumed sessions.':
'未来恢复的会话将默认展开历史记录。',
'History is already expanded in this session.': '当前会话的历史记录已展开。',
'Usage: /history collapse-on-resume|expand-on-resume|expand-now':
'用法:/history collapse-on-resume|expand-on-resume|expand-now',
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.':
'历史记录已折叠:{{n}} 条消息已隐藏。使用 /history expand-now 展开。',
// === Same-as-English optimization ===
' (not in model registry)': '(不在模型注册表中)',
'start server': '启动服务器',

View file

@ -34,6 +34,7 @@ import { forkCommand } from '../ui/commands/forkCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { goalCommand } from '../ui/commands/goalCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { historyCommand } from '../ui/commands/historyCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { importConfigCommand } from '../ui/commands/importConfigCommand.js';
@ -130,6 +131,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
historyCommand,
hooksCommand,
resolvedIdeCommand,
importConfigCommand,

View file

@ -49,6 +49,7 @@ export const createMockCommandContext = (
} as any, // Cast because Logger is a class.
},
ui: {
history: [],
addItem: vi.fn(),
clear: vi.fn(),
setDebugMessage: vi.fn(),
@ -60,6 +61,7 @@ export const createMockCommandContext = (
btwAbortControllerRef: { current: null },
isIdleRef: { current: true },
loadHistory: vi.fn(),
refreshStatic: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),

View file

@ -340,7 +340,10 @@ describe('AppContainer State Management', () => {
vimEnabled: false,
vimMode: 'NORMAL',
});
mockedUseSessionStats.mockReturnValue({ stats: {} });
mockedUseSessionStats.mockReturnValue({
stats: {},
seedPromptCount: vi.fn(),
});
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
@ -2843,6 +2846,105 @@ describe('AppContainer State Management', () => {
expect(lastCall[2]).toBe(1);
});
it('loads a collapsed summary into history on cold-boot resume when collapseOnResume is enabled', async () => {
const historyManager = {
history: [] as HistoryItem[],
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn((items: HistoryItem[]) => {
historyManager.history = items;
}),
truncateToItem: vi.fn(),
};
mockedUseHistory.mockReturnValue(historyManager);
const resumeSessionData = {
conversation: {
sessionId: 'session-1',
projectHash: 'test-project-hash',
startTime: '2024-01-01T00:00:00Z',
lastUpdated: '2024-01-01T00:00:01Z',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'session-1',
timestamp: '2024-01-01T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello' }] },
cwd: '/test/workspace',
version: '1.0.0',
},
{
uuid: 'a1',
parentUuid: 'u1',
sessionId: 'session-1',
timestamp: '2024-01-01T00:00:01Z',
type: 'assistant',
message: { role: 'model', parts: [{ text: 'world' }] },
cwd: '/test/workspace',
version: '1.0.0',
},
],
},
filePath: '/tmp/session.jsonl',
lastCompletedUuid: 'a1',
};
vi.spyOn(mockConfig, 'getContentGenerator').mockReturnValue({
useSummarizedThinking: vi.fn(() => false),
} as unknown as ReturnType<typeof mockConfig.getContentGenerator>);
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'getResumedSessionData').mockReturnValue(
resumeSessionData as ReturnType<
typeof mockConfig.getResumedSessionData
>,
);
vi.spyOn(mockConfig, 'loadPausedBackgroundAgents').mockResolvedValue([]);
mockSettings = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
history: {
collapseOnResume: true,
},
},
},
} as LoadedSettings;
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await vi.waitFor(() => {
expect(historyManager.loadHistory).toHaveBeenCalled();
});
expect(historyManager.loadHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ display: { kind: 'collapse-summary' } }),
]),
);
expect(historyManager.history.at(-1)).toMatchObject({
type: 'info',
display: { kind: 'collapse-summary' },
});
expect(
historyManager.history
.slice(0, -1)
.every((item) => item.display?.suppressOnRestore === true),
).toBe(true);
});
it('does not remeasure footer height for sticky todo status-only updates', async () => {
// Scoped stub: makeFakeConfig().initialize() rejects on React's
// double-mount, which leaks async renders and destabilizes the

View file

@ -67,9 +67,12 @@ import {
readWorktreeSessionMarker,
isSessionRuntimeActive,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import {
applyCollapsePolicyAndSummary,
buildResumedHistoryItems,
expandCollapsedHistory,
} from './utils/resumeHistoryUtils.js';
import { loadLowlight } from './utils/lowlightLoader.js';
import { restoreGoalFromHistory } from './utils/restoreGoal.js';
import {
getStickyTodos,
getStickyTodoMaxVisibleItems,
@ -123,6 +126,7 @@ import {
computeApiTruncationIndex,
isRealUserTurn,
} from './utils/historyMapping.js';
import { restoreGoalFromHistory } from './utils/restoreGoal.js';
import {
useVimModeState,
useVimModeActions,
@ -591,9 +595,13 @@ export const AppContainer = (props: AppContainerProps) => {
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
resumedSessionData,
config,
const rawItems = buildResumedHistoryItems(resumedSessionData, config);
const collapseOnResume =
settings.merged.ui?.history?.collapseOnResume ?? false;
const historyItems = applyCollapsePolicyAndSummary(
rawItems,
collapseOnResume,
);
historyManager.loadHistory(historyItems);
@ -1090,6 +1098,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleResume,
} = useResumeCommand({
config,
settings,
historyManager,
startNewSession,
setSessionName,
@ -1098,6 +1107,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { handleBranch } = useBranchCommand({
config,
settings,
historyManager,
startNewSession,
setSessionName,
@ -1257,10 +1267,11 @@ export const AppContainer = (props: AppContainerProps) => {
} = useSlashCommandProcessor(
config,
settings,
historyManager.history,
historyManager.addItem,
historyManager.clearItems,
historyManager.loadHistory,
remountStaticHistory,
refreshStatic,
toggleVimEnabled,
isProcessing,
setIsProcessing,
@ -2675,7 +2686,12 @@ export const AppContainer = (props: AppContainerProps) => {
!(option === 'both' && hasRestoreFailure)
) {
const originalHistory = historyManager.history;
const originalLength = originalHistory.length;
const hasSummary = originalHistory.some(
(h) => h.display?.kind === 'collapse-summary',
);
const effectiveLength = hasSummary
? originalHistory.length - 1
: originalHistory.length;
let targetTurnIndex = 0;
for (const h of originalHistory) {
@ -2685,7 +2701,11 @@ export const AppContainer = (props: AppContainerProps) => {
geminiClient.truncateHistory(apiTruncateIndex);
const truncatedUi = originalHistory.filter((h) => h.id < userItem.id);
// Strip suppressOnRestore flags and filter out collapse-summary items
// so rewound items remain visible without stale summary text
const truncatedUi = expandCollapsedHistory(
originalHistory.filter((h) => h.id < userItem.id),
);
historyManager.loadHistory(truncatedUi);
refreshStatic();
@ -2706,7 +2726,7 @@ export const AppContainer = (props: AppContainerProps) => {
config.getChatRecordingService()?.rewindRecording(
targetTurnIndex,
{ truncatedCount: originalLength - truncatedUi.length },
{ truncatedCount: effectiveLength - truncatedUi.length },
!hasRestoreFailure
? config
.getFileHistoryService()

View file

@ -0,0 +1,141 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { historyCommand } from './historyCommand.js';
import { MessageType, type HistoryItem } from '../types.js';
import type { CommandContext } from './types.js';
describe('historyCommand', () => {
let mockHistory: HistoryItem[];
let mockLoadHistory: ReturnType<typeof vi.fn>;
let mockRefreshStatic: ReturnType<typeof vi.fn>;
let mockSettings: { setValue: ReturnType<typeof vi.fn> };
let mockContext: CommandContext;
beforeEach(() => {
mockHistory = [
{ id: 1, type: 'user', text: 'hello' } as HistoryItem,
{ id: 2, type: 'gemini', text: 'hi' } as HistoryItem,
];
mockLoadHistory = vi.fn((newHistory) => {
mockHistory = newHistory;
});
mockRefreshStatic = vi.fn();
mockSettings = {
setValue: vi.fn(),
};
mockContext = {
ui: {
history: mockHistory,
loadHistory: mockLoadHistory,
refreshStatic: mockRefreshStatic,
},
services: {
settings: mockSettings,
},
} as unknown as CommandContext;
});
it('collapse-on-resume sets the user preference', async () => {
const collapseCommand = historyCommand.subCommands!.find(
(c) => c.name === 'collapse-on-resume',
)!;
const result = await collapseCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('History will be collapsed by default'),
});
expect(mockSettings.setValue).toHaveBeenCalledWith(
'User',
'ui.history.collapseOnResume',
true,
);
});
it('expand-on-resume sets the user preference', async () => {
const expandCommand = historyCommand.subCommands!.find(
(c) => c.name === 'expand-on-resume',
)!;
const result = await expandCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('History will be expanded by default'),
});
expect(mockSettings.setValue).toHaveBeenCalledWith(
'User',
'ui.history.collapseOnResume',
false,
);
});
it('expand-now removes suppressOnRestore and drops summary', async () => {
const expandNowCommand = historyCommand.subCommands!.find(
(c) => c.name === 'expand-now',
)!;
// Setup collapsed state
mockHistory = [
{
id: 1,
type: 'user',
text: 'hello',
display: { suppressOnRestore: true },
} as HistoryItem,
{
id: 2,
type: 'gemini',
text: 'hi',
display: { suppressOnRestore: true },
} as HistoryItem,
{
id: 3,
type: MessageType.INFO,
text: 'History collapsed: 2 messages hidden.',
display: { kind: 'collapse-summary' },
} as HistoryItem,
];
mockContext.ui.history = mockHistory;
const result = await expandNowCommand.action!(mockContext, '');
expect(result).toBeUndefined();
expect(mockLoadHistory).toHaveBeenCalledWith([
expect.objectContaining({ id: 1, display: undefined }),
expect.objectContaining({ id: 2, display: undefined }),
]);
expect(mockRefreshStatic).toHaveBeenCalled();
});
it('expand-now returns already expanded when expanding an uncollapsed session', async () => {
const expandNowCommand = historyCommand.subCommands!.find(
(c) => c.name === 'expand-now',
)!;
const result = await expandNowCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'History is already expanded in this session.',
});
expect(mockLoadHistory).not.toHaveBeenCalled();
expect(mockRefreshStatic).not.toHaveBeenCalled();
});
it('returns usage error for unknown subcommand', async () => {
const result = await historyCommand.action!(mockContext, 'unknown');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
});
});
});

View file

@ -0,0 +1,107 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, MessageActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { SettingScope } from '../../config/settings.js';
import { expandCollapsedHistory } from '../utils/resumeHistoryUtils.js';
const collapseOnResumeCommand: SlashCommand = {
name: 'collapse-on-resume',
get description() {
return t('Set history to collapse by default when resuming a session');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: (context): MessageActionReturn | void => {
const { settings } = context.services;
settings.setValue(SettingScope.User, 'ui.history.collapseOnResume', true);
return {
type: 'message',
messageType: 'info',
content: t(
'History will be collapsed by default for future resumed sessions.',
),
};
},
};
const expandOnResumeCommand: SlashCommand = {
name: 'expand-on-resume',
get description() {
return t('Set history to expand by default when resuming a session');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: (context): MessageActionReturn | void => {
const { settings } = context.services;
settings.setValue(SettingScope.User, 'ui.history.collapseOnResume', false);
return {
type: 'message',
messageType: 'info',
content: t(
'History will be expanded by default for future resumed sessions.',
),
};
},
};
const expandNowCommand: SlashCommand = {
name: 'expand-now',
get description() {
return t('Expand the currently collapsed history transcript');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: (context): MessageActionReturn | void => {
const { history, loadHistory, refreshStatic } = context.ui;
const hasSuppressed = history.some(
(item) => item.display?.suppressOnRestore,
);
if (!hasSuppressed) {
return {
type: 'message',
messageType: 'info',
content: t('History is already expanded in this session.'),
};
}
// Remove suppressOnRestore from all items and drop collapse summary items.
const updated = expandCollapsedHistory(history);
loadHistory(updated);
refreshStatic();
// No return — the loadHistory/refreshStatic calls handle the UI update
},
};
export const historyCommand: SlashCommand = {
name: 'history',
get description() {
return t('Control history display preferences and visibility');
},
argumentHint: 'collapse-on-resume|expand-on-resume|expand-now',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
subCommands: [
collapseOnResumeCommand,
expandOnResumeCommand,
expandNowCommand,
],
action: async () => ({
type: 'message',
messageType: 'error',
content: t(
'Usage: /history collapse-on-resume|expand-on-resume|expand-now',
),
}),
};

View file

@ -53,6 +53,8 @@ export interface CommandContext {
};
// UI state and history management
ui: {
/** The current history items. */
history: HistoryItem[];
/** Adds a new item to the history display. */
addItem: UseHistoryManagerReturn['addItem'];
/** Clears all history items and the console screen. */
@ -86,6 +88,8 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Refreshes the static history display in Ink. */
refreshStatic: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void | Promise<void>;

View file

@ -511,6 +511,42 @@ describe('<MainContent />', () => {
expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(53);
});
it('filters out suppressed history items from rendering', async () => {
staticItemsSpy.mockClear();
historyItemDisplayPropsSpy.mockClear();
const history = [
{ type: 'user' as const, id: 1, text: 'hello' },
{
type: 'gemini' as const,
id: 2,
text: 'hi',
display: { suppressOnRestore: true },
},
{
type: 'info' as const,
id: 3,
text: 'History collapsed: 1 messages hidden.',
display: { kind: 'collapse-summary' as const },
},
];
const uiState = createUIState({ history });
renderMainContent(uiState);
expect(historyItemDisplayPropsSpy).toHaveBeenCalled();
const renderedHistoryItems = historyItemDisplayPropsSpy.mock.calls.map(
(c) => c[0].item,
);
// Unsuppressed user message and collapse-summary should render (summary has no suppressOnRestore)
expect(renderedHistoryItems).toHaveLength(2);
expect(renderedHistoryItems.find((i) => i.id === 1)).toMatchObject({
id: 1,
});
expect(renderedHistoryItems.find((i) => i.id === 3)).toMatchObject({
id: 3,
});
// Suppressed gemini item should NOT render
expect(renderedHistoryItems.find((i) => i.id === 2)).toBeUndefined();
});
it('does NOT reset progressive replay when only currentModel changes (PR #4119 regression guard)', async () => {
// Wenshao's review on PR #4119: if AppContainer splits the model-change
// wiring into two separate effects (setCurrentModel first, refreshStatic

View file

@ -12,6 +12,7 @@ import { ShowMoreLines } from './ShowMoreLines.js';
import { Notifications } from './Notifications.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
@ -104,6 +105,7 @@ const virtualIsStaticItem = (item: HistoryItem) => item.id > 0;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const uiActions = useUIActions();
const { compactMode, compactInline } = useCompactMode();
const {
pendingHistoryItems,
@ -114,6 +116,12 @@ export const MainContent = () => {
historyRemountKey,
} = uiState;
// Filter out items whose display is suppressed (e.g. /history collapse).
const visibleHistory = useMemo(
() => uiState.history.filter((item) => !item.display?.suppressOnRestore),
[uiState.history],
);
// Set of callIds whose label is absorbed by a compact-mode tool_group header.
// Computed from RAW history (not merged) — force-expand status depends only
// on the tool_group's own state, and mergeable groups don't change force-
@ -141,7 +149,7 @@ export const MainContent = () => {
if (!compactMode || !uiState.useTerminalBuffer || compactInline)
return EMPTY_ABSORBED_CALL_IDS;
const absorbed = new Set<string>();
for (const item of uiState.history) {
for (const item of visibleHistory) {
if (item.type !== 'tool_group') continue;
if (
isForceExpandGroup(
@ -170,7 +178,7 @@ export const MainContent = () => {
}, [
compactMode,
compactInline,
uiState.history,
visibleHistory,
uiState.embeddedShellFocused,
uiState.activePtyId,
uiState.useTerminalBuffer,
@ -191,22 +199,22 @@ export const MainContent = () => {
const prevMergedHistoryRef = useRef<HistoryItem[] | null>(null);
const mergedHistory = useMemo(() => {
if (!compactMode) {
prevMergedHistoryRef.current = uiState.history;
return uiState.history;
prevMergedHistoryRef.current = visibleHistory;
return visibleHistory;
}
// <Static> is append-only: merging reduces item count, which <Static>
// can't handle without clearTerminal + remount (the flash we're fixing).
if (!uiState.useTerminalBuffer) {
prevMergedHistoryRef.current = uiState.history;
return uiState.history;
prevMergedHistoryRef.current = visibleHistory;
return visibleHistory;
}
// compactInline: user opted into per-group display without cross-group merging.
if (compactInline) {
prevMergedHistoryRef.current = uiState.history;
return uiState.history;
prevMergedHistoryRef.current = visibleHistory;
return visibleHistory;
}
const next = mergeCompactToolGroups(
uiState.history,
visibleHistory,
uiState.embeddedShellFocused,
uiState.activePtyId,
absorbedCallIds,
@ -227,7 +235,7 @@ export const MainContent = () => {
}, [
compactMode,
compactInline,
uiState.history,
visibleHistory,
uiState.useTerminalBuffer,
uiState.embeddedShellFocused,
uiState.activePtyId,
@ -240,7 +248,7 @@ export const MainContent = () => {
// multiple groups are merged, the first group's summary wins (see below).
const summaryByCallId = useMemo(() => {
const map = new Map<string, string>();
for (const item of uiState.history) {
for (const item of visibleHistory) {
if (item.type === 'tool_use_summary') {
for (const callId of item.precedingToolUseIds) {
// First summary wins — earlier summaries represent the opening
@ -252,7 +260,7 @@ export const MainContent = () => {
}
}
return map;
}, [uiState.history]);
}, [visibleHistory]);
const isSummaryAbsorbed = useCallback(
(item: HistoryItem | HistoryItemWithoutId): boolean => {
@ -286,6 +294,35 @@ export const MainContent = () => {
[summaryByCallId, uiState.useTerminalBuffer, compactInline],
);
// Ink's <Static> is append-only: once an item is rendered to the terminal
// buffer, it cannot be replaced. In compact mode, when a new tool_group is
// merged into a previous one, the merged result has FEWER items than the
// raw history. Static would not re-render the older items even though their
// content changed, so we explicitly call refreshStatic() to clear the
// terminal and re-render the merged view.
//
// Detection: if history length grew but mergedHistory length did NOT grow
// proportionally (i.e., a merge consolidated items), trigger a refresh.
const prevHistoryLengthRef = useRef(visibleHistory.length);
const prevMergedLengthRef = useRef(mergedHistory.length);
useEffect(() => {
if (!compactMode) {
prevHistoryLengthRef.current = visibleHistory.length;
prevMergedLengthRef.current = mergedHistory.length;
return;
}
const prevHLen = prevHistoryLengthRef.current;
const currHLen = visibleHistory.length;
const prevMLen = prevMergedLengthRef.current;
const currMLen = mergedHistory.length;
// History grew, but merged length stayed same or shrank → a merge happened.
if (currHLen > prevHLen && currMLen <= prevMLen) {
uiActions.refreshStatic();
}
prevHistoryLengthRef.current = currHLen;
prevMergedLengthRef.current = currMLen;
}, [compactMode, visibleHistory, mergedHistory, uiActions]);
// Virtual viewport path short-circuits below before any of the
// <Static>-only machinery is needed. The offsets / progressive-replay
// state still computes because it lives at the top of the component, but

View file

@ -100,7 +100,7 @@ export interface UIActions {
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
handleResume: (sessionId: string) => Promise<void>;
// Branch (fork) session
handleBranch: (name?: string) => Promise<void>;
// Delete session dialog

View file

@ -151,6 +151,7 @@ describe('useSlashCommandProcessor', () => {
handleResume: vi.fn(),
handleBranch: vi.fn().mockResolvedValue(undefined),
openDeleteDialog: vi.fn(),
openDiffDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
@ -161,7 +162,6 @@ describe('useSlashCommandProcessor', () => {
openMcpDialog: vi.fn(),
openHooksDialog: vi.fn(),
openRewindSelector: vi.fn(),
openDiffDialog: vi.fn(),
});
beforeEach(() => {
@ -199,6 +199,7 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor(
mockConfig,
settings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,
@ -220,8 +221,11 @@ describe('useSlashCommandProcessor', () => {
};
describe('Initialization and Command Loading', () => {
it('should initialize CommandService with all required loaders', () => {
setupProcessorHook();
it('should initialize CommandService with all required loaders', async () => {
const result = setupProcessorHook();
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
@ -518,19 +522,175 @@ describe('useSlashCommandProcessor', () => {
expect(mockOpenModelDialog).toHaveBeenCalled();
});
it('should handle "dialog: memory" action', async () => {
const command = createTestCommand({
it('awaits direct resume session switching before returning handled', async () => {
const actions = createMockActions();
let resolveResume: (() => void) | undefined;
actions.handleResume = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveResume = resolve;
}),
);
const resumeCommand = createTestCommand({
name: 'resume-direct',
action: vi.fn().mockResolvedValue({
type: 'dialog',
dialog: 'resume',
sessionId: 'session-123',
}),
});
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze([resumeCommand]));
const { result } = renderHook(() =>
useSlashCommandProcessor(
mockConfig,
mockSettings,
[],
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(),
vi.fn(),
false,
vi.fn(),
{ current: true },
vi.fn(),
actions,
new Map(),
true,
null,
mockUpdateItem,
),
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let settled = false;
const promise = result.current
.handleSlashCommand('/resume-direct')
.then(() => {
settled = true;
});
await waitFor(() => {
expect(actions.handleResume).toHaveBeenCalledWith('session-123');
});
expect(settled).toBe(false);
resolveResume?.();
await act(async () => {
await promise;
});
expect(settled).toBe(true);
});
it('shows info feedback for collapse-on-resume command', async () => {
const historyCmd = createTestCommand({
name: 'history',
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content:
'History will be collapsed by default for future resumed sessions.',
}),
});
const result = setupProcessorHook([historyCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/history collapse-on-resume');
});
// Should have 2 calls: user message + info feedback
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenNthCalledWith(
1,
{
type: MessageType.USER,
text: '/history collapse-on-resume',
sentToModel: false,
},
expect.any(Number),
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2,
{
type: MessageType.INFO,
text: 'History will be collapsed by default for future resumed sessions.',
},
expect.any(Number),
);
});
it('expand-now command updates history without info feedback', async () => {
const historyCmd = createTestCommand({
name: 'history',
subCommands: [
{
name: 'expand-now',
description: 'Expand collapsed history',
kind: CommandKind.BUILT_IN,
action: vi.fn().mockResolvedValue(undefined),
},
],
});
const result = setupProcessorHook([historyCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/history expand-now');
});
// User message added, no info feedback (action returns void)
expect(mockAddItem).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.USER,
text: '/history expand-now',
sentToModel: false,
},
expect.any(Number),
);
});
it('opens memory dialog when command returns dialog:memory', async () => {
const actions = createMockActions();
const memoryCommand = createTestCommand({
name: 'memorycmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'memory' }),
});
const result = setupProcessorHook([command]);
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze([memoryCommand]));
const { result } = renderHook(() =>
useSlashCommandProcessor(
mockConfig,
mockSettings,
[],
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(),
vi.fn(),
false,
vi.fn(),
{ current: true },
vi.fn(),
actions,
new Map(),
true,
null,
mockUpdateItem,
),
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/memorycmd');
});
expect(mockOpenMemoryDialog).toHaveBeenCalled();
expect(actions.openMemoryDialog).toHaveBeenCalled();
});
it('should pass interactive execution mode to command actions', async () => {
@ -1430,6 +1590,7 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor(
mockConfig,
mockSettings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,
@ -1481,6 +1642,7 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor(
mockConfig,
mockSettings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,
@ -1547,6 +1709,7 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor(
mockConfig,
mockSettings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,
@ -1617,6 +1780,7 @@ describe('useSlashCommandProcessor', () => {
return useSlashCommandProcessor(
mockConfig,
mockSettings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,
@ -1676,6 +1840,7 @@ describe('useSlashCommandProcessor', () => {
useSlashCommandProcessor(
mockConfig,
mockSettings,
[], // mock history array
mockAddItem,
mockClearItems,
mockLoadHistory,

View file

@ -7,6 +7,7 @@
import {
useCallback,
useMemo,
useLayoutEffect,
useEffect,
useRef,
useState,
@ -93,6 +94,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'delete',
'branch',
'btw',
'history',
]);
export interface SlashCommandProcessorActions {
@ -108,7 +110,7 @@ export interface SlashCommandProcessorActions {
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
handleResume: (sessionId: string) => void;
handleResume: (sessionId: string) => Promise<void>;
handleBranch: (name?: string) => Promise<void>;
openDeleteDialog: () => void;
quit: (messages: HistoryItem[]) => void;
@ -133,6 +135,7 @@ export interface SlashCommandProcessorActions {
export const useSlashCommandProcessor = (
config: Config | null,
settings: LoadedSettings,
history: HistoryItem[],
addItem: UseHistoryManagerReturn['addItem'],
clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'],
@ -149,6 +152,13 @@ export const useSlashCommandProcessor = (
updateItem: UseHistoryManagerReturn['updateItem'],
setSessionName?: (name: string | null) => void,
) => {
// Ref avoids adding `history` to the commandContext useMemo deps,
// which would cause a full context rebuild on every history append.
const historyRef = useRef(history);
useLayoutEffect(() => {
historyRef.current = history;
}, [history]);
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [recentCommands, setRecentCommands] = useState<
@ -322,6 +332,9 @@ export const useSlashCommandProcessor = (
logger,
},
ui: {
get history() {
return historyRef.current;
},
addItem,
clear: () => {
cancelBtw();
@ -331,6 +344,7 @@ export const useSlashCommandProcessor = (
setSessionName?.(null);
},
loadHistory,
refreshStatic,
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
@ -765,7 +779,7 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
case 'resume':
if (result.sessionId) {
actions.handleResume(result.sessionId);
await actions.handleResume(result.sessionId);
} else {
actions.openResumeDialog(result.matchedSessions);
}

View file

@ -8,11 +8,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useBranchCommand } from './useBranchCommand.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../utils/restoreGoal.js', () => ({
restoreGoalFromHistory: vi.fn(() => ({ restored: false })),
}));
const mockSettings = {
merged: { ui: { history: { collapseOnResume: false } } },
} as unknown as LoadedSettings;
describe('useBranchCommand', () => {
let forkSession: ReturnType<typeof vi.fn>;
let loadSession: ReturnType<typeof vi.fn>;
@ -33,6 +38,7 @@ describe('useBranchCommand', () => {
const makeOptions = () => ({
config,
settings: mockSettings,
historyManager: { clearItems, loadHistory, addItem },
startNewSession: startNewSessionUI,
setSessionName,
@ -486,4 +492,35 @@ describe('useBranchCommand', () => {
expect.any(Number),
);
});
it('applies collapse policy when collapseOnResume is true', async () => {
const settingsWithCollapse = {
merged: { ui: { history: { collapseOnResume: true } } },
} as unknown as LoadedSettings;
const { result } = renderHook(() =>
useBranchCommand({
...makeOptions(),
settings: settingsWithCollapse,
}),
);
await act(async () => {
await result.current.handleBranch('my-branch');
});
// loadHistory should have been called with items that include
// suppressOnRestore and a collapse-summary item.
expect(loadHistory).toHaveBeenCalledTimes(1);
const loadedItems = loadHistory.mock.calls[0][0];
expect(loadedItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
display: expect.objectContaining({ suppressOnRestore: true }),
}),
expect.objectContaining({
display: expect.objectContaining({ kind: 'collapse-summary' }),
}),
]),
);
});
});

View file

@ -13,9 +13,13 @@ import {
SessionStartSource,
computeUniqueBranchTitle,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import {
buildResumedHistoryItems,
applyCollapsePolicyAndSummary,
} from '../utils/resumeHistoryUtils.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { LoadedSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
/**
@ -51,6 +55,7 @@ function deriveFirstPrompt(messages: ChatRecord[]): string {
export interface UseBranchCommandOptions {
config: Config | null;
settings: LoadedSettings;
historyManager: Pick<
UseHistoryManagerReturn,
'clearItems' | 'loadHistory' | 'addItem'
@ -149,7 +154,13 @@ export function useBranchCommand(
// set immediately after the UI commits so any subsequent
// failure (title, hook, remount, announce) skips the catch
// block's core rollback.
const uiHistoryItems = buildResumedHistoryItems(resumed, config);
const rawItems = buildResumedHistoryItems(resumed, config);
const collapseOnResume =
options.settings.merged.ui?.history?.collapseOnResume ?? false;
const uiHistoryItems = applyCollapsePolicyAndSummary(
rawItems,
collapseOnResume,
);
startNewSession(newSessionId);
historyManager.clearItems();
historyManager.loadHistory(uiHistoryItems);
@ -258,7 +269,14 @@ export function useBranchCommand(
);
}
},
[config, historyManager, startNewSession, setSessionName, remount],
[
config,
historyManager,
startNewSession,
setSessionName,
remount,
options.settings.merged.ui?.history?.collapseOnResume,
],
);
return { handleBranch };

View file

@ -10,8 +10,21 @@ import {
BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE,
useResumeCommand,
} from './useResumeCommand.js';
import { useHistory } from './useHistoryManager.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings = {
merged: {
ui: {
history: {
collapseOnResume: false,
},
},
},
} as unknown as LoadedSettings;
const resumeMocks = vi.hoisted(() => {
let resolveLoadSession:
| ((value: { conversation: unknown } | undefined) => void)
@ -40,15 +53,24 @@ const resumeMocks = vi.hoisted(() => {
};
});
vi.mock('../utils/resumeHistoryUtils.js', () => ({
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
}));
vi.mock('../utils/resumeHistoryUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/resumeHistoryUtils.js')>();
return {
...actual,
buildResumedHistoryItems: vi.fn(() => [
{ id: 1, type: 'user', text: 'hi' },
]),
};
});
vi.mock('../utils/restoreGoal.js', () => ({
restoreGoalFromHistory: vi.fn(() => ({ restored: false })),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
class SessionService {
constructor(_cwd: string) {}
async loadSession(_sessionId: string) {
@ -65,19 +87,42 @@ vi.mock('@qwen-code/qwen-code-core', () => {
}
return {
...original,
SessionService,
};
});
describe('useResumeCommand', () => {
it('should initialize with dialog closed', () => {
const { result } = renderHook(() => useResumeCommand());
const { result } = renderHook(() =>
useResumeCommand({
settings: mockSettings,
config: null,
historyManager: {
addItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
},
startNewSession: vi.fn(),
}),
);
expect(result.current.isResumeDialogOpen).toBe(false);
});
it('should open the dialog when openResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
const { result } = renderHook(() =>
useResumeCommand({
settings: mockSettings,
config: null,
historyManager: {
addItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
},
startNewSession: vi.fn(),
}),
);
act(() => {
result.current.openResumeDialog();
@ -87,7 +132,18 @@ describe('useResumeCommand', () => {
});
it('should close the dialog when closeResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
const { result } = renderHook(() =>
useResumeCommand({
settings: mockSettings,
config: null,
historyManager: {
addItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
},
startNewSession: vi.fn(),
}),
);
// Open the dialog first
act(() => {
@ -105,7 +161,21 @@ describe('useResumeCommand', () => {
});
it('should maintain stable function references across renders', () => {
const { result, rerender } = renderHook(() => useResumeCommand());
const historyManager = {
addItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
};
const startNewSession = vi.fn();
const { result, rerender } = renderHook(() =>
useResumeCommand({
settings: mockSettings,
config: null,
historyManager,
startNewSession,
}),
);
const initialOpenFn = result.current.openResumeDialog;
const initialCloseFn = result.current.closeResumeDialog;
@ -129,6 +199,7 @@ describe('useResumeCommand', () => {
const { result } = renderHook(() =>
useResumeCommand({
config: null,
settings: mockSettings,
historyManager,
startNewSession,
}),
@ -159,6 +230,7 @@ describe('useResumeCommand', () => {
const resetMonitorRegistry = vi.fn();
const config = {
getSessionId: () => 'old-session-id',
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
@ -195,6 +267,7 @@ describe('useResumeCommand', () => {
const { result } = renderHook(() =>
useResumeCommand({
config,
settings: mockSettings,
historyManager,
startNewSession,
}),
@ -245,6 +318,92 @@ describe('useResumeCommand', () => {
);
});
it('applies collapseOnResume policy when resuming a session', async () => {
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi.fn(),
};
const resetMonitorRegistry = vi.fn();
const config = {
getSessionId: () => 'old-session-id',
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getBackgroundTaskRegistry: () => ({
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
getRunning: vi.fn().mockReturnValue([]),
reset: resetMonitorRegistry,
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
abortAll: vi.fn(),
}),
loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]),
getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }),
getDebugLogger: () => ({
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const settingsWithCollapse = {
merged: {
ui: {
history: {
collapseOnResume: true,
},
},
},
} as unknown as LoadedSettings;
const { result } = renderHook(() => {
const historyManager = useHistory();
const resumeCommand = useResumeCommand({
config,
settings: settingsWithCollapse,
historyManager,
startNewSession,
});
return { historyManager, resumeCommand };
});
let resumePromise: Promise<void> | undefined;
act(() => {
resumePromise = result.current.resumeCommand.handleResume('session-3');
});
resumeMocks.resolvePendingLoadSession({
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
});
await act(async () => {
await resumePromise;
});
// Verify that the history state contains the suppressed item and the summary item
const history = result.current.historyManager.history;
expect(history).toEqual(
expect.arrayContaining([
expect.objectContaining({
display: expect.objectContaining({ suppressOnRestore: true }),
}),
expect.objectContaining({
display: expect.objectContaining({ kind: 'collapse-summary' }),
}),
]),
);
});
it('adds a recovered-background-agents notice when paused agents are restored', async () => {
const historyManager = {
addItem: vi.fn(),
@ -260,6 +419,7 @@ describe('useResumeCommand', () => {
.mockReturnValue('Recovered 2 interrupted background agents.');
const config = {
getSessionId: () => 'old-session-id',
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
@ -298,6 +458,7 @@ describe('useResumeCommand', () => {
const { result } = renderHook(() =>
useResumeCommand({
config,
settings: mockSettings,
historyManager,
startNewSession,
}),
@ -356,6 +517,7 @@ describe('useResumeCommand', () => {
const { result } = renderHook(() =>
useResumeCommand({
config,
settings: mockSettings,
historyManager,
startNewSession,
}),
@ -425,6 +587,7 @@ describe('useResumeCommand', () => {
const { result } = renderHook(() =>
useResumeCommand({
config,
settings: mockSettings,
historyManager,
startNewSession,
}),
@ -450,4 +613,89 @@ describe('useResumeCommand', () => {
expect.any(Number),
);
});
it('rolls core back to the old session when something fails after core swap but before UI swap', async () => {
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi
.fn()
.mockRejectedValueOnce(new Error('init boom'))
.mockResolvedValueOnce(undefined),
};
const config = {
getSessionId: () => 'old-session-id',
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getBackgroundTaskRegistry: () => ({
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
getRunning: vi.fn().mockReturnValue([]),
reset: vi.fn(),
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
abortAll: vi.fn(),
}),
loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]),
getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }),
getDebugLogger: () => ({
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const historyManager = {
addItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
};
const { result } = renderHook(() =>
useResumeCommand({
config,
settings: mockSettings,
historyManager,
startNewSession,
}),
);
await act(async () => {
await result.current.handleResume('new-session-id');
});
// Core was swapped to the new session, then rolled back to the old one.
expect(config.startNewSession).toHaveBeenNthCalledWith(
1,
'new-session-id',
expect.any(Object),
);
expect(config.startNewSession).toHaveBeenNthCalledWith(
2,
'old-session-id',
undefined,
);
// UI never swapped.
expect(startNewSession).not.toHaveBeenCalled();
expect(historyManager.clearItems).not.toHaveBeenCalled();
expect(historyManager.loadHistory).not.toHaveBeenCalled();
// User sees the failure.
expect(historyManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/Failed to resume session.*init boom/),
}),
expect.any(Number),
);
});
});

View file

@ -10,7 +10,10 @@ import {
type Config,
type SessionListItem,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import {
buildResumedHistoryItems,
applyCollapsePolicyAndSummary,
} from '../utils/resumeHistoryUtils.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItemWithoutId } from '../types.js';
@ -18,9 +21,11 @@ import {
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';
import type { LoadedSettings } from '../../config/settings.js';
export interface UseResumeCommandOptions {
config: Config | null;
settings: LoadedSettings;
historyManager: Pick<
UseHistoryManagerReturn,
'addItem' | 'clearItems' | 'loadHistory'
@ -49,7 +54,7 @@ const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE =
"Stop the current session's running background tasks before resuming another session.";
export function useResumeCommand(
options?: UseResumeCommandOptions,
options: UseResumeCommandOptions,
): UseResumeCommandResult {
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
const [resumeMatchedSessions, setResumeMatchedSessions] = useState<
@ -69,99 +74,150 @@ export function useResumeCommand(
setResumeMatchedSessions(undefined);
}, []);
const { config, historyManager, startNewSession, setSessionName, remount } =
options ?? {};
const {
config,
settings,
historyManager,
startNewSession,
setSessionName,
remount,
} = options;
const hasHistoryManager = !!historyManager;
const { addItem, clearItems, loadHistory } = historyManager || {};
const { addItem, clearItems, loadHistory } = historyManager;
const handleResume = useCallback(
async (sessionId: string) => {
if (!config || !hasHistoryManager || !startNewSession) {
if (!config) {
return;
}
if (hasBlockingBackgroundWork(config)) {
closeResumeDialog();
const blockedMessage: HistoryItemWithoutId = {
type: MessageType.ERROR,
text: BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE,
};
addItem?.(blockedMessage, Date.now());
addItem(blockedMessage, Date.now());
closeResumeDialog();
return;
}
// Close dialog immediately to prevent input capture during async operations.
closeResumeDialog();
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
const oldSessionId = config.getSessionId();
let coreSwapped = false;
let uiSwapped = false;
if (!sessionData) {
return;
}
// Start new session in UI context.
startNewSession(sessionId);
// Restore session name tag from custom title.
const customTitle = sessionService.getSessionTitle(sessionId);
setSessionName?.(customTitle ?? null);
// Reset UI history.
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
clearItems?.();
loadHistory?.(uiHistoryItems);
// Update session history core.
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(sessionId, sessionData);
// Re-arm /goal: the in-memory activeGoalStore entry (if any) is stale
// after `config.startNewSession` rebuilds the hook system — its
// `setAt` was captured before /new, and its `hookId` points to a
// hook that no longer exists. The cold-boot path runs this same
// call in AppContainer; the runtime /resume path needs it too,
// otherwise the footer pill keeps ticking from the original setAt
// (visible as "几十秒" elapsed immediately after /new + /resume) and
// the Stop hook is silently dead until the user re-issues /goal.
try {
if (addItem) restoreGoalFromHistory(uiHistoryItems, config, addItem);
} catch {
// Best-effort — never block resume on goal restoration.
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return;
}
// Restore session name tag from custom title.
const customTitle = sessionService.getSessionTitle(sessionId);
// Build UI history items.
const rawItems = buildResumedHistoryItems(sessionData, config);
const collapseOnResume =
settings.merged.ui?.history?.collapseOnResume ?? false;
const uiHistoryItems = applyCollapsePolicyAndSummary(
rawItems,
collapseOnResume,
);
// 1. Swap core first. Matches useBranchCommand's core-before-UI
// pattern: if anything fails between core swap and UI swap,
// the catch block rolls core back to the old session so the
// user is not stranded with a half-live client.
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(sessionId, sessionData);
coreSwapped = true;
// Re-arm /goal: the in-memory activeGoalStore entry (if any) is stale
// after `config.startNewSession` rebuilds the hook system — its
// `setAt` was captured before /new, and its `hookId` points to a
// hook that no longer exists. The cold-boot path runs this same
// call in AppContainer; the runtime /resume path needs it too,
// otherwise the footer pill keeps ticking from the original setAt
// (visible as "几十秒" elapsed immediately after /new + /resume) and
// the Stop hook is silently dead until the user re-issues /goal.
try {
restoreGoalFromHistory(uiHistoryItems, config, addItem);
} catch {
// Best-effort — never block resume on goal restoration.
}
// Rebuild turn boundary tracking so rewind works within resumed sessions.
config
.getChatRecordingService()
?.rebuildTurnBoundaries(sessionData.conversation.messages);
await config.getGeminiClient()?.initialize?.();
const recovered = await config.loadPausedBackgroundAgents(sessionId);
if (recovered.length > 0) {
const recoveredMessage: HistoryItemWithoutId = {
type: MessageType.INFO,
text: config
.getBackgroundAgentResumeService()
.buildRecoveredBackgroundAgentsNotice(recovered.length),
};
addItem(recoveredMessage, Date.now());
}
// 2. Swap UI. Once this commits, rolling core back is unsafe —
// it would leave UI on the resumed session but recorder writing
// into the old JSONL (split-brain).
startNewSession(sessionId);
setSessionName?.(customTitle ?? null);
clearItems();
loadHistory(uiHistoryItems);
uiSwapped = true;
// SessionStart hook is handled during chat initialization so its
// additionalContext can be injected into the resumed model context.
// Refresh terminal UI.
remount?.();
} catch (error) {
if (coreSwapped && !uiSwapped) {
// Core switched to the resumed session but UI hasn't swapped
// yet — put core back on the old session, otherwise the
// recorder would keep writing new user messages into the
// orphaned session JSONL while UI still shows the old session.
try {
config.startNewSession(oldSessionId, undefined);
} catch (rollbackErr) {
config
.getDebugLogger()
.warn(
`Rollback after failed /resume init failed: ${rollbackErr}`,
);
}
}
addItem(
{
type: MessageType.ERROR,
text: `Failed to resume session: ${error instanceof Error ? error.message : String(error)}`,
} as HistoryItemWithoutId,
Date.now(),
);
closeResumeDialog();
remount?.();
}
// Rebuild turn boundary tracking so rewind works within resumed sessions.
config
.getChatRecordingService()
?.rebuildTurnBoundaries(sessionData.conversation.messages);
await config.getGeminiClient()?.initialize?.();
const recovered = await config.loadPausedBackgroundAgents(sessionId);
if (recovered.length > 0) {
const recoveredMessage: HistoryItemWithoutId = {
type: MessageType.INFO,
text: config
.getBackgroundAgentResumeService()
.buildRecoveredBackgroundAgentsNotice(recovered.length),
};
addItem?.(recoveredMessage, Date.now());
}
// SessionStart hook is handled during chat initialization so its
// additionalContext can be injected into the resumed model context.
// Refresh terminal UI.
remount?.();
},
[
closeResumeDialog,
config,
hasHistoryManager,
addItem,
clearItems,
loadHistory,
startNewSession,
setSessionName,
remount,
settings.merged.ui?.history?.collapseOnResume,
],
);

View file

@ -14,10 +14,12 @@ import type { ExtensionUpdateAction } from '../state/extensions.js';
*/
export function createNonInteractiveUI(): CommandContext['ui'] {
return {
history: [],
addItem: (_item, _timestamp) => 0,
clear: () => {},
setDebugMessage: (_message) => {},
loadHistory: (_newHistory) => {},
refreshStatic: () => {},
pendingItem: null,
setPendingItem: (_item) => {},
btwItem: null,

View file

@ -91,6 +91,20 @@ export interface SummaryProps {
export interface HistoryItemBase {
text?: string; // Text content for user/gemini/info/error messages
/** Display-only flags that do not affect canonical history semantics. */
display?: {
/**
* If true, the item is kept in history for turn mapping but not
* rendered in the restored transcript. Set by ui.history.collapseOnResume
* when resuming a session.
*/
suppressOnRestore?: boolean;
/**
* Identifies special display-only items, like the summary row added
* when history is collapsed.
*/
kind?: 'collapse-summary';
};
}
export type HistoryItemUser = HistoryItemBase & {

View file

@ -424,5 +424,14 @@ describe('isRealUserTurn', () => {
it('returns false for non-user items', () => {
expect(isRealUserTurn(geminiItem(1))).toBe(false);
expect(
isRealUserTurn({ type: 'info', id: 1, text: 'info' } as HistoryItem),
).toBe(false);
});
it('returns true for user items with suppressOnRestore', () => {
const item = userItem(1, 'hello world');
item.display = { suppressOnRestore: true };
expect(isRealUserTurn(item)).toBe(true);
});
});

View file

@ -5,7 +5,11 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildResumedHistoryItems } from './resumeHistoryUtils.js';
import {
buildResumedHistoryItems,
stripSuppressOnRestore,
expandCollapsedHistory,
} from './resumeHistoryUtils.js';
import { ToolCallStatus } from '../types.js';
import type {
AnyDeclarativeTool,
@ -14,6 +18,7 @@ import type {
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import type { HistoryItem } from '../types.js';
const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
({
@ -460,3 +465,122 @@ describe('resumeHistoryUtils', () => {
expect(items[0]).not.toHaveProperty('sentToModel');
});
});
describe('stripSuppressOnRestore', () => {
it('returns item unchanged when display is undefined', () => {
const item = { id: 1, type: 'user', text: 'hello' } as HistoryItem;
expect(stripSuppressOnRestore(item)).toBe(item);
});
it('returns item unchanged when suppressOnRestore is absent', () => {
const item = {
id: 1,
type: 'user',
text: 'hello',
display: {},
} as HistoryItem;
const result = stripSuppressOnRestore(item);
expect(result).toEqual({
id: 1,
type: 'user',
text: 'hello',
display: {},
});
});
it('strips suppressOnRestore while preserving other display properties', () => {
const item = {
id: 1,
type: 'user',
text: 'hello',
display: { suppressOnRestore: true, kind: 'collapse-summary' },
} as HistoryItem;
const result = stripSuppressOnRestore(item);
expect(result).toEqual({
id: 1,
type: 'user',
text: 'hello',
display: { kind: 'collapse-summary' },
});
});
it('sets display to undefined when suppressOnRestore was the only property', () => {
const item = {
id: 1,
type: 'user',
text: 'hello',
display: { suppressOnRestore: true },
} as HistoryItem;
const result = stripSuppressOnRestore(item);
expect(result).toEqual({
id: 1,
type: 'user',
text: 'hello',
display: undefined,
});
});
});
describe('expandCollapsedHistory', () => {
it('returns empty array for empty input', () => {
expect(expandCollapsedHistory([])).toEqual([]);
});
it('filters out collapse-summary items and strips suppressOnRestore', () => {
const items = [
{
id: 1,
type: 'user',
text: 'hello',
display: { suppressOnRestore: true },
},
{
id: 2,
type: 'gemini',
text: 'hi',
display: { suppressOnRestore: true },
},
{
id: 3,
type: 'info',
text: 'Summary',
display: { kind: 'collapse-summary' },
},
] as HistoryItem[];
const result = expandCollapsedHistory(items);
expect(result).toEqual([
{ id: 1, type: 'user', text: 'hello', display: undefined },
{ id: 2, type: 'gemini', text: 'hi', display: undefined },
]);
});
it('preserves items without suppressOnRestore', () => {
const items = [
{ id: 1, type: 'user', text: 'hello' },
{
id: 2,
type: 'gemini',
text: 'hi',
display: { suppressOnRestore: true },
},
] as HistoryItem[];
const result = expandCollapsedHistory(items);
expect(result).toEqual([
{ id: 1, type: 'user', text: 'hello' },
{ id: 2, type: 'gemini', text: 'hi', display: undefined },
]);
});
it('handles items with both suppressOnRestore and kind', () => {
const items = [
{
id: 1,
type: 'user',
text: 'hello',
display: { suppressOnRestore: true, kind: 'collapse-summary' },
},
] as HistoryItem[];
const result = expandCollapsedHistory(items);
expect(result).toEqual([]);
});
});

View file

@ -17,10 +17,12 @@ import type {
} from '@qwen-code/qwen-code-core';
import type {
HistoryItem,
HistoryItemInfo,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { ToolCallStatus, MessageType } from '../types.js';
import { t } from '../../i18n/index.js';
/**
* Extracts text content from a Content object's parts (excluding thought parts).
@ -502,3 +504,78 @@ export function buildResumedHistoryItems(
return items;
}
/**
* Applies the quiet-restore display policy to resumed history items.
* Marks each item with `display.suppressOnRestore` so the rendering layer
* skips them while the canonical history (used by /rewind turn mapping) is preserved.
*/
function applyResumeDisplayPolicy(items: HistoryItem[]): HistoryItem[] {
return items.map((item) => ({
...item,
display: { ...item.display, suppressOnRestore: true },
}));
}
/**
* Creates the summary INFO item shown when resume-time collapse suppresses
* the transcript display.
*/
function createHistoryCollapseSummaryItem(
messageCount: number,
): HistoryItemInfo & { display: { kind: 'collapse-summary' } } {
const n = String(messageCount);
return {
type: MessageType.INFO,
text: t(
'History collapsed: {{n}} messages hidden. Use /history expand-now to show.',
{ n },
),
display: { kind: 'collapse-summary' },
};
}
/**
* Strips the suppressOnRestore flag from a history item's display property.
* Used when rewinding into collapsed history to ensure rewound items remain visible.
*/
export function stripSuppressOnRestore(item: HistoryItem): HistoryItem {
if (!item.display?.suppressOnRestore) return item;
const { suppressOnRestore: _, ...rest } = item.display;
return {
...item,
display: Object.keys(rest).length > 0 ? rest : undefined,
};
}
/**
* Removes collapse-summary items and strips suppressOnRestore from the rest.
* Shared between the rewind path and the expand-now command.
*/
export function expandCollapsedHistory(items: HistoryItem[]): HistoryItem[] {
return items
.filter((item) => item.display?.kind !== 'collapse-summary')
.map(stripSuppressOnRestore);
}
/**
* Helper to apply the collapse policy and append the summary item if needed.
*/
export function applyCollapsePolicyAndSummary(
rawItems: HistoryItem[],
collapseOnResume: boolean,
): HistoryItem[] {
if (!collapseOnResume) return rawItems;
const uiHistoryItems = applyResumeDisplayPolicy(rawItems);
if (rawItems.length > 0) {
const nextId = rawItems[rawItems.length - 1].id + 1;
return [
...uiHistoryItems,
{ id: nextId, ...createHistoryCollapseSummaryItem(rawItems.length) },
];
}
return uiHistoryItems;
}

View file

@ -219,6 +219,17 @@
"type": "boolean",
"default": false
},
"history": {
"description": "History display settings.",
"type": "object",
"properties": {
"collapseOnResume": {
"description": "Whether to collapse history by default when resuming a session.",
"type": "boolean",
"default": false
}
}
},
"showLineNumbers": {
"description": "Show line numbers in the code output.",
"type": "boolean",