Skip to content

Commit b242190

Browse files
authored
Merge pull request #58 from hqwlkj/feature/dropdown-menu-refactor
Feat: 新增 DropdownMenu 组件
2 parents d5ad0fb + d3aab4d commit b242190

6 files changed

Lines changed: 477 additions & 65 deletions

File tree

src/session.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ export type MessageMeta = {
121121
resultMd?: string;
122122
asThinking?: boolean;
123123
isSummary?: boolean;
124+
isModelChange?: boolean;
125+
modelConfig?: {
126+
model: string;
127+
thinkingEnabled: boolean;
128+
reasoningEffort?: string;
129+
};
124130
skill?: SkillInfo;
125131
};
126132

@@ -598,7 +604,7 @@ The candidate skills are as follows:\n\n`;
598604
if (!fs.existsSync(root)) {
599605
return [];
600606
}
601-
let entries: fs.Dirent[] = [];
607+
let entries: fs.Dirent[];
602608
try {
603609
entries = fs.readdirSync(root, { withFileTypes: true });
604610
} catch {
@@ -1326,6 +1332,25 @@ ${skillMd}
13261332
return messages;
13271333
}
13281334

1335+
addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void {
1336+
const now = new Date().toISOString();
1337+
const message: SessionMessage = {
1338+
id: crypto.randomUUID(),
1339+
sessionId,
1340+
role: "system",
1341+
content,
1342+
contentParams: null,
1343+
messageParams: null,
1344+
compacted: false,
1345+
visible: true,
1346+
createTime: now,
1347+
updateTime: now,
1348+
meta,
1349+
};
1350+
this.appendSessionMessage(sessionId, message);
1351+
this.onAssistantMessage(message, false);
1352+
}
1353+
13291354
private normalizeSessionMessage(message: SessionMessage): SessionMessage {
13301355
if (message.role !== "tool") {
13311356
return message;

src/tests/dropdownMenu.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { calculateVisibleStart } from "../ui/DropdownMenu";
4+
5+
test("calculateVisibleStart centers active item when possible", () => {
6+
// 10 items, max 5 visible, active index 4 (middle)
7+
// Should show items 2-6 (start at 2)
8+
const start = calculateVisibleStart(4, 10, 5);
9+
assert.equal(start, 2);
10+
});
11+
12+
test("calculateVisibleStart handles active item at the beginning", () => {
13+
// 10 items, max 5 visible, active index 0
14+
// Should show items 0-4 (start at 0)
15+
const start = calculateVisibleStart(0, 10, 5);
16+
assert.equal(start, 0);
17+
});
18+
19+
test("calculateVisibleStart handles active item at the end", () => {
20+
// 10 items, max 5 visible, active index 9 (last)
21+
// Should show items 5-9 (start at 5)
22+
const start = calculateVisibleStart(9, 10, 5);
23+
assert.equal(start, 5);
24+
});
25+
26+
test("calculateVisibleStart handles fewer items than maxVisible", () => {
27+
// 3 items, max 5 visible, active index 1
28+
// Should show all items (start at 0)
29+
const start = calculateVisibleStart(1, 3, 5);
30+
assert.equal(start, 0);
31+
});
32+
33+
test("calculateVisibleStart handles single item", () => {
34+
// 1 item, max 5 visible, active index 0
35+
// Should start at 0
36+
const start = calculateVisibleStart(0, 1, 5);
37+
assert.equal(start, 0);
38+
});
39+
40+
test("calculateVisibleStart handles empty list", () => {
41+
// 0 items, max 5 visible, active index 0
42+
// Should start at 0
43+
const start = calculateVisibleStart(0, 0, 5);
44+
assert.equal(start, 0);
45+
});
46+
47+
test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => {
48+
// 10 items, max 7 visible (odd), active index 2
49+
// floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0
50+
const start = calculateVisibleStart(2, 10, 7);
51+
assert.equal(start, 0);
52+
});
53+
54+
test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => {
55+
// 10 items, max 6 visible (even), active index 2
56+
// floor((6-1)/2) = 2, so 2-2 = 0
57+
const start = calculateVisibleStart(2, 10, 6);
58+
assert.equal(start, 0);
59+
});
60+
61+
test("calculateVisibleStart keeps active item centered in middle range", () => {
62+
// 20 items, max 5 visible, active index 10
63+
// floor((5-1)/2) = 2, so 10-2 = 8
64+
const start = calculateVisibleStart(10, 20, 5);
65+
assert.equal(start, 8);
66+
});
67+
68+
test("calculateVisibleStart handles activeIndex at exact boundary", () => {
69+
// 10 items, max 5 visible, active index 2 (boundary where centering starts)
70+
// floor((5-1)/2) = 2, so 2-2 = 0
71+
const start = calculateVisibleStart(2, 10, 5);
72+
assert.equal(start, 0);
73+
});
74+
75+
test("calculateVisibleStart handles activeIndex just after boundary", () => {
76+
// 10 items, max 5 visible, active index 3
77+
// floor((5-1)/2) = 2, so 3-2 = 1
78+
const start = calculateVisibleStart(3, 10, 5);
79+
assert.equal(start, 1);
80+
});
81+
82+
test("calculateVisibleStart handles large maxVisible", () => {
83+
// 10 items, max 100 visible, active index 5
84+
// Should show all items (start at 0)
85+
const start = calculateVisibleStart(5, 10, 100);
86+
assert.equal(start, 0);
87+
});
88+
89+
test("calculateVisibleStart handles activeIndex equal to totalItems", () => {
90+
// 10 items, max 5 visible, active index 10 (out of bounds)
91+
// floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5)
92+
const start = calculateVisibleStart(10, 10, 5);
93+
assert.equal(start, 5);
94+
});
95+
96+
test("calculateVisibleStart with maxVisible of 1", () => {
97+
// 5 items, max 1 visible, active index 2
98+
// floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1)
99+
const start = calculateVisibleStart(2, 5, 1);
100+
assert.equal(start, 2);
101+
});
102+
103+
test("calculateVisibleStart with maxVisible of 1 at end", () => {
104+
// 5 items, max 1 visible, active index 4 (last)
105+
// floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1)
106+
const start = calculateVisibleStart(4, 5, 1);
107+
assert.equal(start, 4);
108+
});
109+
110+
test("calculateVisibleStart scrolling behavior - moving down", () => {
111+
// Simulate scrolling through a list
112+
// 10 items, max 5 visible
113+
114+
// Start at index 0
115+
assert.equal(calculateVisibleStart(0, 10, 5), 0);
116+
117+
// Move to index 2 (still centered)
118+
assert.equal(calculateVisibleStart(2, 10, 5), 0);
119+
120+
// Move to index 5 (window should scroll)
121+
assert.equal(calculateVisibleStart(5, 10, 5), 3);
122+
123+
// Move to index 8 (near end)
124+
assert.equal(calculateVisibleStart(8, 10, 5), 5);
125+
126+
// Move to index 9 (at end)
127+
assert.equal(calculateVisibleStart(9, 10, 5), 5);
128+
});
129+
130+
test("calculateVisibleStart scrolling behavior - moving up", () => {
131+
// Simulate scrolling up through a list
132+
// 10 items, max 5 visible
133+
134+
// Start at index 9 (end)
135+
assert.equal(calculateVisibleStart(9, 10, 5), 5);
136+
137+
// Move to index 6
138+
assert.equal(calculateVisibleStart(6, 10, 5), 4);
139+
140+
// Move to index 4 (window should scroll up)
141+
assert.equal(calculateVisibleStart(4, 10, 5), 2);
142+
143+
// Move to index 1 (near start)
144+
assert.equal(calculateVisibleStart(1, 10, 5), 0);
145+
146+
// Move to index 0 (at start)
147+
assert.equal(calculateVisibleStart(0, 10, 5), 0);
148+
});

src/ui/App.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import OpenAI from "openai";
88
import {
99
SessionManager,
1010
type LlmStreamProgress,
11+
type MessageMeta,
1112
type SessionEntry,
1213
type SessionMessage,
1314
type SessionStatus,
@@ -268,12 +269,47 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
268269
const { changed } = writeModelConfigSelection(selection, current, projectRoot);
269270
const next = resolveCurrentSettings(projectRoot);
270271
setResolvedSettings(next);
272+
271273
if (!changed) {
272274
return "Model settings unchanged";
273275
}
276+
277+
const activeSessionId = sessionManager.getActiveSessionId();
278+
const meta: MessageMeta = {
279+
isModelChange: true,
280+
modelConfig: {
281+
model: selection.model,
282+
thinkingEnabled: selection.thinkingEnabled,
283+
reasoningEffort: selection.reasoningEffort,
284+
},
285+
};
286+
const content = `/model\n⎿ Set model to ${selection.model}`;
287+
288+
if (activeSessionId) {
289+
sessionManager.addSessionSystemMessage(activeSessionId, content, meta);
290+
} else {
291+
const now = new Date().toISOString();
292+
setMessages((prev) => [
293+
...prev,
294+
{
295+
id: crypto.randomUUID(),
296+
sessionId: "local",
297+
role: "system" as const,
298+
content,
299+
contentParams: null,
300+
messageParams: null,
301+
compacted: false,
302+
visible: true,
303+
createTime: now,
304+
updateTime: now,
305+
meta,
306+
},
307+
]);
308+
}
309+
274310
return `Model settings updated: ${formatModelConfig(current)}${formatModelConfig(next)}`;
275311
},
276-
[projectRoot]
312+
[projectRoot, sessionManager]
277313
);
278314

279315
const handleSubmit = useCallback(

0 commit comments

Comments
 (0)