Skip to content

Commit 1df832e

Browse files
committed
feat(chat): 添加继续会话提示功能
- 新增 ContinuePrompt 组件显示中断会话提示对话框 - 在 App 组件中条件渲染 ContinuePrompt 组件 - 扩展 ChatContext 和状态,添加 showContinuePrompt 字段 - 在 reducer 中处理 SESSION_STATUS 设置是否显示继续提示 - 添加 DISMISS_CONTINUE_PROMPT 动作和对应的 reducer 逻辑 - 将用户操作保存到 localStorage,防止重复显示继续提示 - 消息处理逻辑中过滤“Interrupted.”消息,避免重复提示 - 完善 ContinuePrompt 单元测试覆盖显示和交互行为 - 测试中添加对 dismissContinuePrompt 动作的调用验证 - 优化测试环境 localStorage 模拟,使用真实内存存储模拟行为
1 parent 0831b0d commit 1df832e

7 files changed

Lines changed: 489 additions & 16 deletions

File tree

packages/vscode-ide-companion/src/tests/setup.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,19 @@ Object.defineProperty(window, "acquireVsCodeApi", {
8181
value: () => mockVSCode,
8282
});
8383

84-
// Mock localStorage
84+
// Mock localStorage with real in-memory storage
85+
const storageStore = new Map<string, string>();
8586
const localStorageMock = {
86-
getItem: vi.fn(),
87-
setItem: vi.fn(),
88-
removeItem: vi.fn(),
89-
clear: vi.fn(),
87+
getItem: vi.fn((key: string) => storageStore.get(key) ?? null),
88+
setItem: vi.fn((key: string, value: string) => {
89+
storageStore.set(key, value);
90+
}),
91+
removeItem: vi.fn((key: string) => {
92+
storageStore.delete(key);
93+
}),
94+
clear: vi.fn(() => {
95+
storageStore.clear();
96+
}),
9097
};
9198
Object.defineProperty(window, "localStorage", {
9299
writable: true,

packages/vscode-ide-companion/src/webview/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Messages from "@/webview/components/Messages";
44
import InputPrompt from "@/webview/components/InputPrompt";
55
import ThinkingLiveBubble from "@/webview/components/ThinkingLiveBubble";
66
import PermissionPrompt from "@/webview/components/PermissionPrompt";
7+
import ContinuePrompt from "@/webview/components/ContinuePrompt";
78
import { useChat } from "@/webview/context/ChatProvider";
89
import AskQuestionCarousel from "@/webview/components/AskQuestionCarousel";
910
import type { AskUserQuestionMetadata } from "@/webview/components/bubbles/ToolBubble";
@@ -47,6 +48,9 @@ export default function App() {
4748
activeSessionId={state.activeSessionId}
4849
onInterrupt={actions.interrupt}
4950
/>
51+
{state.showContinuePrompt && (
52+
<ContinuePrompt onContinue={actions.dismissContinuePrompt} onDismiss={actions.dismissContinuePrompt} />
53+
)}
5054
{state.loading && (
5155
<ThinkingLiveBubble
5256
llmStreamProgress={state.llmStreamProgress}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Unit tests for ContinuePrompt component
3+
*
4+
* Tests cover:
5+
* - Renders the continue dialog correctly
6+
* - Continue button triggers onContinue callback
7+
* - X dismiss button triggers onDismiss callback
8+
* - Dialog displays correct title and description text
9+
*/
10+
11+
import React from "react";
12+
import { render, screen, fireEvent } from "@testing-library/react";
13+
import { describe, it, expect, vi } from "vitest";
14+
import ContinuePrompt from "./ContinuePrompt";
15+
16+
describe("ContinuePrompt", () => {
17+
it("renders the continue dialog with correct title", () => {
18+
render(<ContinuePrompt onContinue={vi.fn()} onDismiss={vi.fn()} />);
19+
20+
expect(screen.getByText("Session interrupted")).toBeInTheDocument();
21+
});
22+
23+
it("renders the description text", () => {
24+
render(<ContinuePrompt onContinue={vi.fn()} onDismiss={vi.fn()} />);
25+
26+
expect(
27+
screen.getByText("The previous operation was interrupted. Do you want to continue the conversation?")
28+
).toBeInTheDocument();
29+
});
30+
31+
it("renders a Continue button", () => {
32+
render(<ContinuePrompt onContinue={vi.fn()} onDismiss={vi.fn()} />);
33+
34+
const continueButton = screen.getByRole("button", { name: /continue/i });
35+
expect(continueButton).toBeInTheDocument();
36+
});
37+
38+
it("calls onContinue when Continue button is clicked", () => {
39+
const onContinue = vi.fn();
40+
const onDismiss = vi.fn();
41+
42+
render(<ContinuePrompt onContinue={onContinue} onDismiss={onDismiss} />);
43+
44+
const continueButton = screen.getByRole("button", { name: /continue/i });
45+
fireEvent.click(continueButton);
46+
47+
expect(onContinue).toHaveBeenCalledTimes(1);
48+
expect(onDismiss).not.toHaveBeenCalled();
49+
});
50+
51+
it("calls onDismiss when X close button is clicked", () => {
52+
const onContinue = vi.fn();
53+
const onDismiss = vi.fn();
54+
55+
render(<ContinuePrompt onContinue={onContinue} onDismiss={onDismiss} />);
56+
57+
// The X button has title="Dismiss"
58+
const dismissButton = screen.getByTitle("Dismiss");
59+
fireEvent.click(dismissButton);
60+
61+
expect(onDismiss).toHaveBeenCalledTimes(1);
62+
expect(onContinue).not.toHaveBeenCalled();
63+
});
64+
65+
it("renders the dialog within a bordered container", () => {
66+
const { container } = render(<ContinuePrompt onContinue={vi.fn()} onDismiss={vi.fn()} />);
67+
68+
// The dialog should have the rounded border class
69+
const dialogContainer = container.querySelector(".rounded-md.border");
70+
expect(dialogContainer).toBeInTheDocument();
71+
});
72+
73+
it("renders with correct layout structure", () => {
74+
const { container } = render(<ContinuePrompt onContinue={vi.fn()} onDismiss={vi.fn()} />);
75+
76+
// Should have flex layout for title and close button
77+
const headerRow = container.querySelector(".flex.items-center.justify-between");
78+
expect(headerRow).toBeInTheDocument();
79+
80+
// Should have a button group
81+
const buttonGroup = container.querySelector(".flex.gap-2");
82+
expect(buttonGroup).toBeInTheDocument();
83+
});
84+
85+
it("can be called multiple times with different callbacks", () => {
86+
const onContinue1 = vi.fn();
87+
const onDismiss1 = vi.fn();
88+
const onContinue2 = vi.fn();
89+
const onDismiss2 = vi.fn();
90+
91+
const { rerender } = render(<ContinuePrompt onContinue={onContinue1} onDismiss={onDismiss1} />);
92+
93+
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
94+
expect(onContinue1).toHaveBeenCalledTimes(1);
95+
96+
rerender(<ContinuePrompt onContinue={onContinue2} onDismiss={onDismiss2} />);
97+
98+
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
99+
expect(onContinue2).toHaveBeenCalledTimes(1);
100+
expect(onContinue1).toHaveBeenCalledTimes(1);
101+
});
102+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Button } from "@/webview/components/ui/button";
2+
import { X } from "lucide-react";
3+
4+
export interface ContinuePromptProps {
5+
onContinue: () => void;
6+
onDismiss: () => void;
7+
}
8+
9+
export default function ContinuePrompt({ onContinue, onDismiss }: ContinuePromptProps) {
10+
return (
11+
<div className="px-4 py-2 w-full max-w-237.5 mx-auto min-w-sm">
12+
<div className="rounded-md border border-(--vscode-focusBorder) bg-(--vscode-editor-background) p-3 text-sm">
13+
<div className="flex items-center justify-between mb-2">
14+
<span className="font-medium">Session interrupted</span>
15+
<Button
16+
variant="ghost"
17+
size="icon-sm"
18+
className="cursor-pointer border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
19+
onClick={onDismiss}
20+
title="Dismiss"
21+
>
22+
<X className="h-4 w-4" />
23+
</Button>
24+
</div>
25+
26+
<p className="text-xs text-muted-foreground mb-3">
27+
The previous operation was interrupted. Do you want to continue the conversation?
28+
</p>
29+
30+
<div className="flex gap-2">
31+
<Button size="sm" variant="default" onClick={onContinue}>
32+
Continue
33+
</Button>
34+
</div>
35+
</div>
36+
</div>
37+
);
38+
}

0 commit comments

Comments
 (0)