Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

21 changes: 0 additions & 21 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo

### Matcher Patterns

<<<<<<< HEAD
`matcher` is a regular expression used to filter trigger conditions.

| Event Type | Events | Matcher Support | Matcher Target |
Expand Down Expand Up @@ -220,26 +219,6 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
}
```

=======
| Event Name | Description | Use Case |
| -------------------- | ------------------------------------------- | ----------------------------------------------- |
| `PreToolUse` | Fired before tool execution | Permission checking, input validation, logging |
| `PostToolUse` | Fired after successful tool execution | Logging, output processing, monitoring |
| `PostToolUseFailure` | Fired when tool execution fails | Error handling, alerting, remediation |
| `Notification` | Fired when notifications are sent | Notification customization, logging |
| `UserPromptSubmit` | Fired when user submits a prompt | Input processing, validation, context injection |
| `SessionStart` | Fired when a new session starts | Initialization, context setup |
| `Stop` | Fired before Qwen concludes its response | Finalization, cleanup |
| `StopFailure` | Fired when turn ends due to API error | Error logging, alerting, rate limit handling |
| `SubagentStart` | Fired when a subagent starts | Subagent initialization |
| `SubagentStop` | Fired when a subagent stops | Subagent finalization |
| `PreCompact` | Fired before conversation compaction | Pre-compaction processing |
| `PostCompact` | Fired after conversation compaction | Summary archiving, usage statistics |
| `SessionEnd` | Fired when a session ends | Cleanup, reporting |
| `PermissionRequest` | Fired when permission dialogs are displayed | Permission automation, policy enforcement |

> > > > > > > main

## Input/Output Rules

### Hook Input Structure
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: true,
description:
'Show welcome back dialog when returning to a project with conversation history.',
'Show welcome back dialog when returning to a project with conversation history. Choosing "Start new chat session" suppresses the dialog for that project until the project summary changes.',
showInDialog: true,
},
enableUserFeedback: {
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/src/ui/hooks/useWelcomeBack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWelcomeBack } from './useWelcomeBack.js';

const coreMocks = vi.hoisted(() => ({
getProjectSummaryInfo: vi.fn(),
getWelcomeBackState: vi.fn(),
saveWelcomeBackRestartChoice: vi.fn().mockResolvedValue(undefined),
clearWelcomeBackState: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();

return {
...actual,
getProjectSummaryInfo: coreMocks.getProjectSummaryInfo,
getWelcomeBackState: coreMocks.getWelcomeBackState,
saveWelcomeBackRestartChoice: coreMocks.saveWelcomeBackRestartChoice,
clearWelcomeBackState: coreMocks.clearWelcomeBackState,
};
});

describe('useWelcomeBack', () => {
const buffer = {
setText: vi.fn(),
};
const config = {
getDebugLogger: () => ({
debug: vi.fn(),
}),
};
const settings = {
ui: {},
};

beforeEach(() => {
vi.clearAllMocks();
coreMocks.getProjectSummaryInfo.mockResolvedValue({
hasHistory: true,
content: 'summary',
summaryFingerprint: 'summary-v1',
});
coreMocks.getWelcomeBackState.mockResolvedValue(null);
});

it('suppresses the dialog when restart was already chosen for the same summary', async () => {
coreMocks.getWelcomeBackState.mockResolvedValue({
lastChoice: 'restart',
summaryFingerprint: 'summary-v1',
});

const { result } = renderHook(() =>
useWelcomeBack(config as never, vi.fn(), buffer, settings as never),
);

await waitFor(() => {
expect(coreMocks.getProjectSummaryInfo).toHaveBeenCalled();
});

expect(result.current.showWelcomeBackDialog).toBe(false);
expect(result.current.welcomeBackInfo).toBeNull();
});

it('shows the dialog when the summary fingerprint changed', async () => {
coreMocks.getWelcomeBackState.mockResolvedValue({
lastChoice: 'restart',
summaryFingerprint: 'summary-v0',
});

const { result } = renderHook(() =>
useWelcomeBack(config as never, vi.fn(), buffer, settings as never),
);

await waitFor(() => {
expect(result.current.showWelcomeBackDialog).toBe(true);
});

expect(result.current.welcomeBackInfo?.summaryFingerprint).toBe(
'summary-v1',
);
});

it('persists the restart choice for the current summary fingerprint', async () => {
const { result } = renderHook(() =>
useWelcomeBack(config as never, vi.fn(), buffer, settings as never),
);

await waitFor(() => {
expect(result.current.showWelcomeBackDialog).toBe(true);
});

act(() => {
result.current.handleWelcomeBackSelection('restart');
});

expect(coreMocks.saveWelcomeBackRestartChoice).toHaveBeenCalledWith(
'summary-v1',
);
expect(result.current.showWelcomeBackDialog).toBe(false);
});

it('clears persisted state and fills the continue prompt when resuming', async () => {
const { result } = renderHook(() =>
useWelcomeBack(config as never, vi.fn(), buffer, settings as never),
);

await waitFor(() => {
expect(result.current.showWelcomeBackDialog).toBe(true);
});

act(() => {
result.current.handleWelcomeBackSelection('continue');
});

await waitFor(() => {
expect(buffer.setText).toHaveBeenCalledWith(
"@.qwen/PROJECT_SUMMARY.md, Based on our previous conversation,Let's continue?",
);
});

expect(coreMocks.clearWelcomeBackState).toHaveBeenCalled();
});
});
34 changes: 32 additions & 2 deletions packages/cli/src/ui/hooks/useWelcomeBack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

import { useState, useEffect, useCallback } from 'react';
import {
clearWelcomeBackState,
getProjectSummaryInfo,
getWelcomeBackState,
saveWelcomeBackRestartChoice,
type ProjectSummaryInfo,
type Config,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -51,7 +54,16 @@ export function useWelcomeBack(

try {
const info = await getProjectSummaryInfo();
if (info.hasHistory) {
if (!info.hasHistory) {
return;
}

const persistedState = await getWelcomeBackState();
const isRestartSuppressed =
persistedState?.lastChoice === 'restart' &&
persistedState.summaryFingerprint === info.summaryFingerprint;

if (!isRestartSuppressed) {
setWelcomeBackInfo(info);
setShowWelcomeBackDialog(true);
}
Expand All @@ -67,6 +79,24 @@ export function useWelcomeBack(
setWelcomeBackChoice(choice);
setShowWelcomeBackDialog(false);

if (choice === 'restart' && welcomeBackInfo?.summaryFingerprint) {
void saveWelcomeBackRestartChoice(
welcomeBackInfo.summaryFingerprint,
).catch((error) => {
config
.getDebugLogger()
.debug('Failed to persist welcome back restart choice:', error);
});
}

if (choice === 'continue') {
void clearWelcomeBackState().catch((error) => {
config
.getDebugLogger()
.debug('Failed to clear welcome back state:', error);
});
}

if (choice === 'continue' && welcomeBackInfo?.content) {
// Create the context message to fill in the input box
const contextMessage = `@.qwen/PROJECT_SUMMARY.md, Based on our previous conversation,Let's continue?`;
Expand All @@ -77,7 +107,7 @@ export function useWelcomeBack(
}
// If choice is 'restart', just close the dialog and continue normally
},
[welcomeBackInfo],
[config, welcomeBackInfo],
);

const handleWelcomeBackClose = useCallback(() => {
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/utils/projectSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {
clearWelcomeBackState,
getProjectSummaryInfo,
getWelcomeBackState,
saveWelcomeBackRestartChoice,
} from './projectSummary.js';

describe('projectSummary', () => {
let testDir: string;
let originalCwd: string;

beforeEach(async () => {
originalCwd = process.cwd();
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'project-summary-'));
process.chdir(testDir);
});

afterEach(async () => {
process.chdir(originalCwd);
await fs.rm(testDir, { recursive: true, force: true });
});

it('returns hasHistory false when the project summary file is missing', async () => {
await expect(getProjectSummaryInfo()).resolves.toEqual({
hasHistory: false,
});
});

it('includes a summary fingerprint when a project summary exists', async () => {
await fs.mkdir(path.join(testDir, '.qwen'), { recursive: true });
await fs.writeFile(
path.join(testDir, '.qwen', 'PROJECT_SUMMARY.md'),
[
'## Overall Goal',
'Ship the fix.',
'',
'## Current Plan',
'1. [TODO] Reproduce the issue',
'2. [IN PROGRESS] Implement the fix',
'3. [DONE] Add tests',
'',
'---',
'',
'## Summary Metadata',
'**Update time**: 2026-04-16T12:00:00.000Z',
].join('\n'),
'utf-8',
);

const info = await getProjectSummaryInfo();

expect(info.hasHistory).toBe(true);
expect(info.summaryFingerprint).toMatch(/^\d+(\.\d+)?:\d+$/);
expect(info.totalTasks).toBe(3);
expect(info.inProgressCount).toBe(1);
expect(info.pendingTasks).toEqual([
'[TODO] Reproduce the issue',
'[IN PROGRESS] Implement the fix',
]);
});

it('persists and clears the project-scoped welcome back restart choice', async () => {
await saveWelcomeBackRestartChoice('summary-fingerprint');

await expect(getWelcomeBackState()).resolves.toEqual({
lastChoice: 'restart',
summaryFingerprint: 'summary-fingerprint',
});

await clearWelcomeBackState();

await expect(getWelcomeBackState()).resolves.toBeNull();
});
});
Loading
Loading