Skip to content

Commit a7cb529

Browse files
committed
refactor(session): 重构会话列表为抽屉式侧边栏
- 将 Header 组件中的会话列表替换为抽屉式 SessionList 组件 - SessionList 组件引入 Drawer 抽屉,支持会话搜索和分组显示 - 使用 Button 触发抽屉打开关闭,优化界面交互体验 - 调整测试文件对应抽屉组件及按钮的 Mock 实现 - SkillsPanel 中修正 React 导入方式并添加图标显示 - 修复 InputPrompt 测试中获取文本框值的类型断言
1 parent 054d669 commit a7cb529

5 files changed

Lines changed: 141 additions & 120 deletions

File tree

packages/vscode-ide-companion/src/webview/components/Header.tsx

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DrawerTitle,
1616
DrawerTrigger,
1717
} from "@/webview/components/ui/drawer";
18+
import SessionList from "@/webview/components/SessionList";
1819

1920
interface HeaderProps {
2021
sessions: SessionSummary[];
@@ -24,20 +25,12 @@ interface HeaderProps {
2425
}
2526

2627
export default function Header({ sessions, activeSessionId, onSelectSession, onCreateNewSession }: HeaderProps) {
27-
const [drawerOpen, setDrawerOpen] = useState(false);
28-
29-
const handleCloseDrawer = useCallback(() => {
30-
setDrawerOpen(false);
31-
}, []);
32-
3328
const handleSelect = useCallback(
3429
(sessionId: string) => {
35-
setDrawerOpen(false);
3630
onSelectSession(sessionId);
3731
},
3832
[onSelectSession]
3933
);
40-
4134
const activeSessionSummary = useMemo(() => {
4235
const session = sessions.find((s) => s.id === activeSessionId);
4336
if (session) {
@@ -74,34 +67,12 @@ export default function Header({ sessions, activeSessionId, onSelectSession, onC
7467
>
7568
<Settings className="h-4 w-4" />
7669
</Button>
77-
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
78-
<DrawerTrigger asChild>
79-
<Button variant="ghost" size="icon" className="shrink-0 cursor-pointer" title="Show Agent Sessions Sidebar">
80-
<PanelRight className="h-4 w-4" />
81-
</Button>
82-
</DrawerTrigger>
83-
<DrawerContent>
84-
<DrawerHeader>
85-
<DrawerTitle>Sessions</DrawerTitle>
86-
<DrawerDescription>Manage your agent sessions.</DrawerDescription>
87-
</DrawerHeader>
88-
<div className="no-scrollbar overflow-y-auto">
89-
<SessionDropdown
90-
sessions={sessions}
91-
activeSessionId={activeSessionId}
92-
onSelect={handleSelect}
93-
onClose={handleCloseDrawer}
94-
/>
95-
</div>
96-
<DrawerFooter>
97-
<DrawerClose asChild>
98-
<Button onClick={onCreateNewSession} variant="outline" className="cursor-pointer">
99-
New Session
100-
</Button>
101-
</DrawerClose>
102-
</DrawerFooter>
103-
</DrawerContent>
104-
</Drawer>
70+
<SessionList
71+
sessions={sessions}
72+
activeSessionId={activeSessionId}
73+
onSelect={handleSelect}
74+
onCreateNewSession={onCreateNewSession}
75+
/>
10576
</div>
10677
</div>
10778
);

packages/vscode-ide-companion/src/webview/components/InputPrompt.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,13 @@ describe("InputPrompt", () => {
265265
it("restores text when editingMessage changes", () => {
266266
const { rerender } = render(<InputPrompt {...defaultProps} />);
267267

268-
expect(screen.getByRole("textbox").value).toBe("");
268+
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("");
269269

270270
rerender(
271271
<InputPrompt {...defaultProps} editingMessage={{ text: "Editing this message", images: [], skills: [] }} />
272272
);
273273

274-
expect(screen.getByRole("textbox").value).toBe("Editing this message");
274+
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("Editing this message");
275275
});
276276

277277
it("calls onClearEditingMessage when sending while editing", async () => {

packages/vscode-ide-companion/src/webview/components/SessionList.test.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,34 @@ import SessionList from "./SessionList";
1414
import type { SessionSummary } from "@/webview/types";
1515

1616
// Mock dependencies
17-
vi.mock("@/webview/components/ui/input", () => ({
18-
Input: vi.fn(({ value, onChange, ...props }) => (
17+
vi.mock("@/webview/components/ui/input-group", () => ({
18+
InputGroup: vi.fn(({ children }) => <div data-testid="input-group">{children}</div>),
19+
InputGroupAddon: vi.fn(({ children }) => <div data-testid="input-group-addon">{children}</div>),
20+
InputGroupInput: vi.fn(({ value, onChange, ...props }) => (
1921
<input data-testid="search-input" value={value} onChange={onChange} {...props} />
2022
)),
2123
}));
2224

23-
vi.mock("@/webview/components/ui/scroll-area", () => ({
24-
ScrollArea: vi.fn(({ children }) => (
25-
<div data-testid="scroll-area">
25+
vi.mock("@/webview/components/ui/drawer", () => ({
26+
Drawer: vi.fn(({ children, open, onOpenChange }) => (
27+
<div data-testid="drawer" data-open={open}>
2628
{children}
27-
<div data-testid="scroll-bar" />
2829
</div>
2930
)),
30-
ScrollBar: vi.fn(() => <div data-testid="scroll-bar" />),
31+
DrawerTrigger: vi.fn(({ children }) => <div data-testid="drawer-trigger">{children}</div>),
32+
DrawerContent: vi.fn(({ children }) => <div data-testid="drawer-content">{children}</div>),
33+
DrawerHeader: vi.fn(({ children }) => <div data-testid="drawer-header">{children}</div>),
34+
DrawerTitle: vi.fn(({ children }) => <div data-testid="drawer-title">{children}</div>),
35+
DrawerFooter: vi.fn(({ children }) => <div data-testid="drawer-footer">{children}</div>),
36+
DrawerClose: vi.fn(({ children }) => <div data-testid="drawer-close">{children}</div>),
37+
}));
38+
39+
vi.mock("@/webview/components/ui/button", () => ({
40+
Button: vi.fn(({ children, onClick }) => (
41+
<button data-testid="button" onClick={onClick}>
42+
{children}
43+
</button>
44+
)),
3145
}));
3246

3347
vi.mock("@/webview/components/ui/empty", () => ({
@@ -39,9 +53,13 @@ vi.mock("@/webview/components/ui/empty", () => ({
3953
}));
4054

4155
vi.mock("@/webview/components/ui/item", () => ({
42-
Item: vi.fn(({ children }) => <div data-testid="item">{children}</div>),
56+
Item: vi.fn(({ children, onClick }) => (
57+
<div data-testid="item" onClick={onClick}>
58+
{children}
59+
</div>
60+
)),
4361
ItemContent: vi.fn(({ children }) => <div>{children}</div>),
44-
ItemGroup: vi.fn(({ children }) => <div>{children}</div>),
62+
ItemGroup: vi.fn(({ children }) => <div data-testid="item-group">{children}</div>),
4563
ItemActions: vi.fn(({ children }) => <div>{children}</div>),
4664
}));
4765

@@ -73,7 +91,7 @@ describe("SessionList", () => {
7391
sessions: mockSessions,
7492
activeSessionId: null,
7593
onSelect: vi.fn(),
76-
onClose: vi.fn(),
94+
onCreateNewSession: vi.fn(),
7795
};
7896

7997
beforeEach(() => {
@@ -85,9 +103,9 @@ describe("SessionList", () => {
85103
expect(screen.getByTestId("search-input")).toBeInTheDocument();
86104
});
87105

88-
it("renders scroll area", () => {
106+
it("renders drawer", () => {
89107
render(<SessionList {...defaultProps} />);
90-
expect(screen.getByTestId("scroll-area")).toBeInTheDocument();
108+
expect(screen.getByTestId("drawer")).toBeInTheDocument();
91109
});
92110

93111
it("shows empty state when no sessions", () => {
Lines changed: 100 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1-
import { useState, useMemo } from "react";
2-
import { Input } from "@/webview/components/ui/input";
3-
import { ScrollArea, ScrollBar } from "@/webview/components/ui/scroll-area";
1+
import { useState, useMemo, useCallback } from "react";
42
import type { SessionSummary } from "@/webview/types";
5-
import { MessageCircle, Search } from "lucide-react";
3+
import { MessageCircle, PanelRight, Search } from "lucide-react";
64
import { Empty, EmptyDescription, EmptyMedia, EmptyHeader, EmptyTitle } from "@/webview/components/ui/empty";
75
import { cn } from "@/webview/lib/utils";
86
import { Item, ItemActions, ItemContent, ItemGroup } from "./ui/item";
7+
import {
8+
Drawer,
9+
DrawerClose,
10+
DrawerContent,
11+
DrawerFooter,
12+
DrawerHeader,
13+
DrawerTitle,
14+
DrawerTrigger,
15+
} from "@/webview/components/ui/drawer";
16+
import { Button } from "@/webview/components/ui/button";
17+
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/webview/components/ui/input-group";
918

10-
interface SessionDropdownProps {
19+
interface SessionListProps {
1120
sessions: SessionSummary[];
1221
activeSessionId: string | null;
1322
onSelect: (sessionId: string) => void;
14-
onClose: () => void;
23+
onCreateNewSession: () => void;
1524
}
1625

1726
function formatTime(dateString: string): string {
@@ -27,8 +36,13 @@ function formatTime(dateString: string): string {
2736
return `${date.getMonth() + 1}/${date.getDate()}`;
2837
}
2938

30-
export default function SessionList({ sessions, activeSessionId, onSelect, onClose }: SessionDropdownProps) {
39+
export default function SessionList({ sessions, activeSessionId, onSelect, onCreateNewSession }: SessionListProps) {
3140
const [query, setQuery] = useState("");
41+
const [drawerOpen, setDrawerOpen] = useState(false);
42+
43+
const handleCloseDrawer = useCallback(() => {
44+
setDrawerOpen(false);
45+
}, []);
3246

3347
const filtered = useMemo(() => {
3448
const q = query.toLowerCase().trim();
@@ -69,68 +83,85 @@ export default function SessionList({ sessions, activeSessionId, onSelect, onClo
6983
grouped["Today"].length + grouped["Yesterday"].length + grouped["Past Week"].length + grouped["Older"].length;
7084

7185
return (
72-
<div className="flex flex-col h-full w-full">
73-
<div className="p-2 shrink-0">
74-
<div className="relative">
75-
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
76-
<Input
77-
className="pl-8 h-8 text-sm"
78-
placeholder="Search sessions..."
79-
value={query}
80-
onChange={(e) => setQuery(e.target.value)}
81-
autoFocus
82-
/>
83-
</div>
84-
</div>
85-
<ScrollArea className="flex-1 min-h-0">
86-
<div className="p-2 pt-0">
87-
{totalCount === 0 ? (
88-
<Empty className="h-full bg-muted/30">
89-
<EmptyHeader>
90-
<EmptyMedia variant="icon">
91-
<MessageCircle />
92-
</EmptyMedia>
93-
<EmptyTitle>{query ? "No sessions found" : "No sessions yet"}</EmptyTitle>
94-
<EmptyDescription className="max-w-xs text-pretty">
95-
You&apos;re all caught up. New notifications will appear here.
96-
</EmptyDescription>
97-
</EmptyHeader>
98-
</Empty>
99-
) : (
100-
Object.entries(grouped).map(([group, items]) =>
101-
items.length > 0 ? (
102-
<ItemGroup key={group} className="mb-3 last:mb-0">
103-
<div className="text-xs uppercase font-medium tracking-wide px-1.5 py-1 text-muted-foreground">
104-
{group}
105-
</div>
106-
{items.map((s) => (
107-
<Item
108-
key={s.id}
109-
size="xs"
110-
className={cn(
111-
"w-full text-left px-2 py-1.5 rounded-md text-sm flex items-center gap-3 transition-colors cursor-pointer border-none",
112-
s.id === activeSessionId
113-
? "bg-primary text-primary-foreground font-medium"
114-
: "text-foreground hover:bg-accent"
115-
)}
116-
onClick={() => {
117-
onSelect(s.id);
118-
onClose();
119-
}}
120-
>
121-
<ItemContent className="flex-1 truncate">{s.summary || "Untitled"}</ItemContent>
122-
<ItemActions className="shrink-0 text-xs text-muted-foreground">
123-
{formatTime(s.updateTime)}
124-
</ItemActions>
125-
</Item>
126-
))}
127-
</ItemGroup>
128-
) : null
129-
)
130-
)}
86+
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
87+
<DrawerTrigger asChild>
88+
<Button variant="ghost" size="icon" className="shrink-0 cursor-pointer" title="Show Agent Sessions Sidebar">
89+
<PanelRight className="h-4 w-4" />
90+
</Button>
91+
</DrawerTrigger>
92+
<DrawerContent>
93+
<DrawerHeader>
94+
<DrawerTitle>Sessions</DrawerTitle>
95+
<InputGroup className="w-full mt-4">
96+
<InputGroupInput
97+
placeholder="Search sessions..."
98+
value={query}
99+
onChange={(e) => setQuery(e.target.value)}
100+
/>
101+
<InputGroupAddon>
102+
<Search />
103+
</InputGroupAddon>
104+
<InputGroupAddon align="inline-end">
105+
{Object.entries(grouped).reduceRight((acc, [_, items]) => acc + items.length, 0)} results
106+
</InputGroupAddon>
107+
</InputGroup>
108+
</DrawerHeader>
109+
<div className="no-scrollbar overflow-y-auto">
110+
<div className="py-2 px-4 pt-0">
111+
{totalCount === 0 ? (
112+
<Empty className="h-full bg-muted/30">
113+
<EmptyHeader>
114+
<EmptyMedia variant="icon">
115+
<MessageCircle />
116+
</EmptyMedia>
117+
<EmptyTitle>{query ? "No sessions found" : "No sessions yet"}</EmptyTitle>
118+
<EmptyDescription className="max-w-xs text-pretty">
119+
You&apos;re all caught up. New notifications will appear here.
120+
</EmptyDescription>
121+
</EmptyHeader>
122+
</Empty>
123+
) : (
124+
Object.entries(grouped).map(([group, items]) =>
125+
items.length > 0 ? (
126+
<ItemGroup key={group} className="mb-3 last:mb-0">
127+
<div className="text-xs uppercase font-medium tracking-wide px-1.5 py-1 text-muted-foreground">
128+
{group}
129+
</div>
130+
{items.map((s) => (
131+
<Item
132+
key={s.id}
133+
size="xs"
134+
className={cn(
135+
"w-full text-left px-2 py-1.5 rounded-md text-sm flex items-center gap-3 transition-colors cursor-pointer border-none",
136+
s.id === activeSessionId
137+
? "bg-primary text-primary-foreground font-medium"
138+
: "text-foreground hover:bg-accent"
139+
)}
140+
onClick={() => {
141+
onSelect(s.id);
142+
handleCloseDrawer();
143+
}}
144+
>
145+
<ItemContent className="flex-1 truncate">{s.summary || "Untitled"}</ItemContent>
146+
<ItemActions className="shrink-0 text-xs text-muted-foreground">
147+
{formatTime(s.updateTime)}
148+
</ItemActions>
149+
</Item>
150+
))}
151+
</ItemGroup>
152+
) : null
153+
)
154+
)}
155+
</div>
131156
</div>
132-
<ScrollBar />
133-
</ScrollArea>
134-
</div>
157+
<DrawerFooter>
158+
<DrawerClose asChild>
159+
<Button onClick={onCreateNewSession} variant="outline" className="cursor-pointer">
160+
New Session
161+
</Button>
162+
</DrawerClose>
163+
</DrawerFooter>
164+
</DrawerContent>
165+
</Drawer>
135166
);
136167
}

packages/vscode-ide-companion/src/webview/components/SkillsPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import React, { useState } from "react";
22
import type { SkillInfo } from "@/webview/types";
33
import { InputGroupButton } from "@/webview/components/ui/input-group";
44
import {
@@ -51,6 +51,7 @@ export default function SkillsPanel({ availableSkills, selectedSkills, onToggle
5151
onSelect={() => onToggle(skill)}
5252
title={skill.path}
5353
>
54+
<GraduationCap className="h-3 w-3 text-muted-foreground" />
5455
<span className="flex-1 truncate font-medium">{skill.name}</span>
5556
<span className="text-xs text-muted-foreground truncate max-w-50">{skill.path}</span>
5657
</CommandItem>

0 commit comments

Comments
 (0)