mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
3b286b2e04
commit
0ba245ea3d
34 changed files with 1535 additions and 110 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -107,3 +107,4 @@ tmp/
|
|||
# code graph skills
|
||||
.venv
|
||||
.codegraph
|
||||
.qwen/computer-use/installed.json
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'コミットの帰属表示',
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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': 'запустить сервер',
|
||||
|
|
|
|||
|
|
@ -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': '啟動伺服器',
|
||||
|
|
|
|||
|
|
@ -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': '启动服务器',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
141
packages/cli/src/ui/commands/historyCommand.test.ts
Normal file
141
packages/cli/src/ui/commands/historyCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
107
packages/cli/src/ui/commands/historyCommand.ts
Normal file
107
packages/cli/src/ui/commands/historyCommand.ts
Normal 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',
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue