@nextclaw/ui 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/assets/{ChannelsList-uKmkpD25.js → ChannelsList-CobWeI2V.js} +1 -1
- package/dist/assets/ChatPage-ZIdFFVAv.js +43 -0
- package/dist/assets/DocBrowser-D55C0iyl.js +1 -0
- package/dist/assets/{DocBrowser-C7-1sXqo.js → DocBrowser-NSzgVKka.js} +1 -1
- package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-DpgVdRgk.js} +1 -1
- package/dist/assets/{LogoBadge-DDS1sU_U.js → LogoBadge-CHS4YNLw.js} +1 -1
- package/dist/assets/{MarketplacePage-DE0QjYVv.js → MarketplacePage-BFYsRss_.js} +1 -1
- package/dist/assets/MarketplacePage-DII-q-Y1.js +1 -0
- package/dist/assets/{McpMarketplacePage-CeLvv1xy.js → McpMarketplacePage-CPqsGJzz.js} +1 -1
- package/dist/assets/{ModelConfig-D1JtGtQv.js → ModelConfig-Bvuo_IpS.js} +1 -1
- package/dist/assets/{ProviderScopedModelInput-SAJH6nkC.js → ProviderScopedModelInput-BfY8rGsf.js} +1 -1
- package/dist/assets/{ProvidersList-1rKi3aQT.js → ProvidersList-3tlaqwSS.js} +1 -1
- package/dist/assets/{RemoteAccessPage-bIAKxDky.js → RemoteAccessPage-yfbrveNQ.js} +1 -1
- package/dist/assets/{RuntimeConfig-BTk9319O.js → RuntimeConfig-CAd5Kta3.js} +1 -1
- package/dist/assets/{SearchConfig-EjeszXbv.js → SearchConfig-DFwgaAa7.js} +1 -1
- package/dist/assets/{SecretsConfig-cnAXvREZ.js → SecretsConfig-CLFSSoTl.js} +1 -1
- package/dist/assets/{SessionsConfig-BIXiDaK2.js → SessionsConfig-vYrvc2Fk.js} +1 -1
- package/dist/assets/{book-open-DvWqOode.js → book-open-C7TAghTk.js} +1 -1
- package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-5dVFkJyw.js} +1 -1
- package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-DbL4EmiT.js} +1 -1
- package/dist/assets/{config-BeGwf2Ao.js → config-CMiW0yaK.js} +1 -1
- package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-BRLFtf-8.js} +1 -1
- package/dist/assets/{dist-RWNFhxvR.js → dist-BFc_H-lY.js} +1 -1
- package/dist/assets/{dist-B6VMuIQN.js → dist-DP-JKR4G.js} +1 -1
- package/dist/assets/{external-link-U86Acd1t.js → external-link-BkJkiWbH.js} +1 -1
- package/dist/assets/{hash-D-OVfV3Z.js → hash-CbP6-6R9.js} +1 -1
- package/dist/assets/i18n-C_2dKw6w.js +1 -0
- package/dist/assets/index-ChUXhq0G.css +1 -0
- package/dist/assets/{index-8XNPYwJu.js → index-DAE8Srx-.js} +3 -3
- package/dist/assets/{label-CHJ1ATds.js → label-D8yyejJS.js} +1 -1
- package/dist/assets/loader-circle-B0sKKO29.js +1 -0
- package/dist/assets/{logos-U1_qDA3U.js → logos-N3dbS6-I.js} +1 -1
- package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-DyuvlNrg.js} +1 -1
- package/dist/assets/plus-CYXs3JtZ.js +1 -0
- package/dist/assets/{popover-xWbqMnIN.js → popover-BKKWGUaG.js} +1 -1
- package/dist/assets/{react-3YE87-lE.js → react-8EIEQjMP.js} +1 -1
- package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-BGMdiNGq.js} +1 -1
- package/dist/assets/{save-4VRlzkii.js → save-Dh4GQzzX.js} +1 -1
- package/dist/assets/search-DOsLw-P9.js +1 -0
- package/dist/assets/{security-config-CGazBahs.js → security-config-CM_tQRXQ.js} +1 -1
- package/dist/assets/{select-DF-AUoie.js → select-BtIi5fnh.js} +1 -1
- package/dist/assets/skeleton-GbHLjPC0.js +1 -0
- package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-C4O-2jZP.js} +1 -1
- package/dist/assets/{switch-D7JF_RZ-.js → switch-DPegGIa_.js} +1 -1
- package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-x5GZexrF.js} +1 -1
- package/dist/assets/{trash-2-VV8jvziy.js → trash-2-CU3LYIpQ.js} +1 -1
- package/dist/assets/{useConfirmDialog-D6HxybcM.js → useConfirmDialog-S5WsGOGf.js} +1 -1
- package/dist/assets/{useMutation-DBTWPbTg.js → useMutation-DSinpgEq.js} +1 -1
- package/dist/assets/x-Bnco_K8b.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +5 -5
- package/src/components/agents/AgentsPage.test.tsx +37 -1
- package/src/components/agents/AgentsPage.tsx +10 -3
- package/src/components/chat/ChatConversationPanel.test.tsx +69 -3
- package/src/components/chat/ChatConversationPanel.tsx +24 -3
- package/src/components/chat/ChatSidebar.test.tsx +168 -28
- package/src/components/chat/ChatSidebar.tsx +103 -28
- package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
- package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -3
- package/src/components/chat/managers/chat-session-list.manager.ts +14 -2
- package/src/components/chat/ncp/NcpChatPage.tsx +18 -4
- package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
- package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
- package/src/components/chat/stores/chat-session-list.store.ts +6 -1
- package/src/components/chat/useChatSessionTypeState.ts +9 -1
- package/src/lib/i18n.chat.ts +3 -0
- package/dist/assets/ChatPage-CslhBPfT.js +0 -43
- package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
- package/dist/assets/MarketplacePage-BZQW70ti.js +0 -1
- package/dist/assets/i18n-hM3v-3YG.js +0 -1
- package/dist/assets/index-CpxuJa9o.css +0 -1
- package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
- package/dist/assets/plus-CrkO1kob.js +0 -1
- package/dist/assets/search-EX-Papzl.js +0 -1
- package/dist/assets/skeleton-B0mmt1vo.js +0 -1
- package/dist/assets/x-B4sxJkGY.js +0 -1
|
@@ -9,6 +9,7 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
|
|
|
9
9
|
const mocks = vi.hoisted(() => ({
|
|
10
10
|
createSession: vi.fn(),
|
|
11
11
|
setQuery: vi.fn(),
|
|
12
|
+
setListMode: vi.fn(),
|
|
12
13
|
selectSession: vi.fn(),
|
|
13
14
|
docOpen: vi.fn(),
|
|
14
15
|
updateNcpSession: vi.fn(),
|
|
@@ -26,6 +27,7 @@ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
|
|
|
26
27
|
chatSessionListManager: {
|
|
27
28
|
createSession: mocks.createSession,
|
|
28
29
|
setQuery: mocks.setQuery,
|
|
30
|
+
setListMode: mocks.setListMode,
|
|
29
31
|
selectSession: mocks.selectSession
|
|
30
32
|
}
|
|
31
33
|
})
|
|
@@ -99,35 +101,39 @@ vi.mock('@/stores/ui.store', () => ({
|
|
|
99
101
|
selector({ connectionStatus: 'connected' })
|
|
100
102
|
}));
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
104
|
+
function resetSidebarTestState() {
|
|
105
|
+
mocks.createSession.mockReset();
|
|
106
|
+
mocks.setQuery.mockReset();
|
|
107
|
+
mocks.setListMode.mockReset();
|
|
108
|
+
mocks.selectSession.mockReset();
|
|
109
|
+
mocks.docOpen.mockReset();
|
|
110
|
+
mocks.updateNcpSession.mockReset();
|
|
111
|
+
mocks.updateNcpSession.mockResolvedValue({});
|
|
112
|
+
mocks.agents = [];
|
|
113
|
+
mocks.sessionItems = [];
|
|
114
|
+
mocks.isLoading = false;
|
|
115
|
+
|
|
116
|
+
useChatInputStore.setState({
|
|
117
|
+
snapshot: {
|
|
118
|
+
...useChatInputStore.getState().snapshot,
|
|
119
|
+
defaultSessionType: 'native',
|
|
120
|
+
sessionTypeOptions: [
|
|
121
|
+
{ value: 'native', label: 'Native', ready: true },
|
|
122
|
+
{ value: 'codex', label: 'Codex', ready: true }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
useChatSessionListStore.setState({
|
|
127
|
+
snapshot: {
|
|
128
|
+
...useChatSessionListStore.getState().snapshot,
|
|
129
|
+
query: '',
|
|
130
|
+
listMode: 'time-first'
|
|
131
|
+
}
|
|
130
132
|
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('ChatSidebar create and list basics', () => {
|
|
136
|
+
beforeEach(resetSidebarTestState);
|
|
131
137
|
|
|
132
138
|
it('closes the create-session menu after choosing a non-default session type', async () => {
|
|
133
139
|
render(
|
|
@@ -174,6 +180,19 @@ describe('ChatSidebar', () => {
|
|
|
174
180
|
expect(screen.getByText('Configure a provider API key first.')).not.toBeNull();
|
|
175
181
|
});
|
|
176
182
|
|
|
183
|
+
it('renders the lightweight list mode switch in the session header row and toggles to project view', () => {
|
|
184
|
+
render(
|
|
185
|
+
<MemoryRouter>
|
|
186
|
+
<ChatSidebar />
|
|
187
|
+
</MemoryRouter>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(screen.getByText('Sessions')).not.toBeNull();
|
|
191
|
+
fireEvent.click(screen.getByRole('button', { name: 'Project' }));
|
|
192
|
+
|
|
193
|
+
expect(mocks.setListMode).toHaveBeenCalledWith('project-first');
|
|
194
|
+
});
|
|
195
|
+
|
|
177
196
|
it('shows a session type badge for non-native sessions in the list', () => {
|
|
178
197
|
mocks.sessionItems = [
|
|
179
198
|
createSessionItem({
|
|
@@ -255,6 +274,127 @@ describe('ChatSidebar', () => {
|
|
|
255
274
|
expect(screen.getByText('Native Task')).not.toBeNull();
|
|
256
275
|
expect(screen.queryByText('Native')).toBeNull();
|
|
257
276
|
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('ChatSidebar project-first mode', () => {
|
|
280
|
+
beforeEach(resetSidebarTestState);
|
|
281
|
+
|
|
282
|
+
it('shows project groups only in project-first mode and hides sessions without a project', () => {
|
|
283
|
+
useChatSessionListStore.setState({
|
|
284
|
+
snapshot: {
|
|
285
|
+
...useChatSessionListStore.getState().snapshot,
|
|
286
|
+
listMode: 'project-first'
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
mocks.sessionItems = [
|
|
290
|
+
createSessionItem({
|
|
291
|
+
key: 'session:project-1',
|
|
292
|
+
createdAt: '2026-03-19T09:00:00.000Z',
|
|
293
|
+
updatedAt: '2026-03-19T11:05:00.000Z',
|
|
294
|
+
label: 'Project Alpha Task',
|
|
295
|
+
projectRoot: '/tmp/project-alpha',
|
|
296
|
+
projectName: 'project-alpha',
|
|
297
|
+
sessionType: 'native',
|
|
298
|
+
sessionTypeMutable: false,
|
|
299
|
+
messageCount: 2
|
|
300
|
+
}),
|
|
301
|
+
createSessionItem({
|
|
302
|
+
key: 'session:plain-1',
|
|
303
|
+
createdAt: '2026-03-19T08:00:00.000Z',
|
|
304
|
+
updatedAt: '2026-03-19T08:05:00.000Z',
|
|
305
|
+
label: 'Loose Task',
|
|
306
|
+
sessionType: 'native',
|
|
307
|
+
sessionTypeMutable: false,
|
|
308
|
+
messageCount: 1
|
|
309
|
+
})
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
render(
|
|
313
|
+
<MemoryRouter>
|
|
314
|
+
<ChatSidebar />
|
|
315
|
+
</MemoryRouter>
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(screen.getByText('project-alpha')).not.toBeNull();
|
|
319
|
+
expect(screen.getByText('Project Alpha Task')).not.toBeNull();
|
|
320
|
+
expect(screen.queryByText('Loose Task')).toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('lets the user choose a runtime type when creating a project-bound draft', () => {
|
|
324
|
+
useChatSessionListStore.setState({
|
|
325
|
+
snapshot: {
|
|
326
|
+
...useChatSessionListStore.getState().snapshot,
|
|
327
|
+
listMode: 'project-first'
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
mocks.sessionItems = [
|
|
331
|
+
createSessionItem({
|
|
332
|
+
key: 'session:project-2',
|
|
333
|
+
createdAt: '2026-03-19T09:00:00.000Z',
|
|
334
|
+
updatedAt: '2026-03-19T11:05:00.000Z',
|
|
335
|
+
label: 'Grouped Task',
|
|
336
|
+
projectRoot: '/tmp/project-beta',
|
|
337
|
+
projectName: 'project-beta',
|
|
338
|
+
sessionType: 'native',
|
|
339
|
+
sessionTypeMutable: false,
|
|
340
|
+
messageCount: 2
|
|
341
|
+
})
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
render(
|
|
345
|
+
<MemoryRouter>
|
|
346
|
+
<ChatSidebar />
|
|
347
|
+
</MemoryRouter>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
fireEvent.click(screen.getByRole('button', { name: 'New Task · project-beta' }));
|
|
351
|
+
fireEvent.click(screen.getByText('Codex'));
|
|
352
|
+
|
|
353
|
+
expect(mocks.createSession).toHaveBeenCalledWith('codex', '/tmp/project-beta');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('creates immediately when there is only one available runtime type', () => {
|
|
357
|
+
useChatInputStore.setState({
|
|
358
|
+
snapshot: {
|
|
359
|
+
...useChatInputStore.getState().snapshot,
|
|
360
|
+
defaultSessionType: 'native',
|
|
361
|
+
sessionTypeOptions: [{ value: 'native', label: 'Native', ready: true }]
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
useChatSessionListStore.setState({
|
|
365
|
+
snapshot: {
|
|
366
|
+
...useChatSessionListStore.getState().snapshot,
|
|
367
|
+
listMode: 'project-first'
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
mocks.sessionItems = [
|
|
371
|
+
createSessionItem({
|
|
372
|
+
key: 'session:project-3',
|
|
373
|
+
createdAt: '2026-03-19T09:00:00.000Z',
|
|
374
|
+
updatedAt: '2026-03-19T11:05:00.000Z',
|
|
375
|
+
label: 'Single Runtime Task',
|
|
376
|
+
projectRoot: '/tmp/project-gamma',
|
|
377
|
+
projectName: 'project-gamma',
|
|
378
|
+
sessionType: 'native',
|
|
379
|
+
sessionTypeMutable: false,
|
|
380
|
+
messageCount: 2
|
|
381
|
+
})
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
render(
|
|
385
|
+
<MemoryRouter>
|
|
386
|
+
<ChatSidebar />
|
|
387
|
+
</MemoryRouter>
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
fireEvent.click(screen.getByRole('button', { name: 'New Task · project-gamma' }));
|
|
391
|
+
|
|
392
|
+
expect(mocks.createSession).toHaveBeenCalledWith('native', '/tmp/project-gamma');
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('ChatSidebar session item interactions', () => {
|
|
397
|
+
beforeEach(resetSidebarTestState);
|
|
258
398
|
|
|
259
399
|
it('hides the sidebar agent avatar for the main agent but keeps specialist avatars', () => {
|
|
260
400
|
mocks.agents = [
|
|
@@ -7,6 +7,11 @@ import { Input } from '@/components/ui/input';
|
|
|
7
7
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
8
8
|
import { SelectItem } from '@/components/ui/select';
|
|
9
9
|
import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
|
|
10
|
+
import { ChatSidebarListModeSwitch } from '@/components/chat/chat-sidebar-list-mode-switch';
|
|
11
|
+
import {
|
|
12
|
+
ChatSidebarProjectGroups,
|
|
13
|
+
type ChatSidebarProjectGroup
|
|
14
|
+
} from '@/components/chat/chat-sidebar-project-groups';
|
|
10
15
|
import { resolveSessionContextView } from '@/lib/session-context.utils';
|
|
11
16
|
import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-label';
|
|
12
17
|
import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
|
|
@@ -14,6 +19,7 @@ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context
|
|
|
14
19
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
15
20
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
16
21
|
import { useAgents } from '@/hooks/agents/useAgents';
|
|
22
|
+
import { getSessionProjectName } from '@/lib/session-project/session-project.utils';
|
|
17
23
|
import { cn } from '@/lib/utils';
|
|
18
24
|
import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
|
|
19
25
|
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
@@ -41,6 +47,14 @@ type DateGroup = {
|
|
|
41
47
|
items: NcpSessionListItemView[];
|
|
42
48
|
};
|
|
43
49
|
|
|
50
|
+
function getSessionUpdatedAtTimestamp(item: NcpSessionListItemView): number {
|
|
51
|
+
return new Date(item.session.updatedAt).getTime();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sortSessionItemsByUpdatedAtDesc(items: NcpSessionListItemView[]): NcpSessionListItemView[] {
|
|
55
|
+
return [...items].sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
|
|
45
59
|
const now = new Date();
|
|
46
60
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
@@ -74,6 +88,37 @@ function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
|
|
|
74
88
|
return groups;
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
function groupSessionsByProject(items: NcpSessionListItemView[]): ChatSidebarProjectGroup[] {
|
|
92
|
+
const grouped = new Map<string, ChatSidebarProjectGroup>();
|
|
93
|
+
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
const projectRoot = item.session.projectRoot?.trim();
|
|
96
|
+
if (!projectRoot) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const existingGroup = grouped.get(projectRoot);
|
|
100
|
+
const updatedAt = getSessionUpdatedAtTimestamp(item);
|
|
101
|
+
if (existingGroup) {
|
|
102
|
+
existingGroup.items.push(item);
|
|
103
|
+
existingGroup.latestUpdatedAt = Math.max(existingGroup.latestUpdatedAt, updatedAt);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
grouped.set(projectRoot, {
|
|
107
|
+
projectRoot,
|
|
108
|
+
projectName: item.session.projectName?.trim() || getSessionProjectName(projectRoot) || projectRoot,
|
|
109
|
+
items: [item],
|
|
110
|
+
latestUpdatedAt: updatedAt
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...grouped.values()]
|
|
115
|
+
.map((group) => ({
|
|
116
|
+
...group,
|
|
117
|
+
items: sortSessionItemsByUpdatedAtDesc(group.items)
|
|
118
|
+
}))
|
|
119
|
+
.sort((left, right) => right.latestUpdatedAt - left.latestUpdatedAt);
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
function sessionTitle(session: SessionEntryView): string {
|
|
78
123
|
if (session.label && session.label.trim()) {
|
|
79
124
|
return session.label.trim();
|
|
@@ -120,12 +165,15 @@ export function ChatSidebar() {
|
|
|
120
165
|
[agentsQuery.data?.agents]
|
|
121
166
|
);
|
|
122
167
|
|
|
123
|
-
const
|
|
168
|
+
const sortedItems = useMemo(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
|
|
169
|
+
const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
|
|
170
|
+
const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
|
|
124
171
|
const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
|
|
125
172
|
const nonDefaultSessionTypeOptions = useMemo(
|
|
126
173
|
() => inputSnapshot.sessionTypeOptions.filter((option) => option.value !== defaultSessionType),
|
|
127
174
|
[defaultSessionType, inputSnapshot.sessionTypeOptions]
|
|
128
175
|
);
|
|
176
|
+
const isProjectFirstView = listSnapshot.listMode === 'project-first';
|
|
129
177
|
|
|
130
178
|
const handleLanguageSwitch = (nextLang: I18nLanguage) => {
|
|
131
179
|
if (language === nextLang) return;
|
|
@@ -164,6 +212,34 @@ export function ChatSidebar() {
|
|
|
164
212
|
}
|
|
165
213
|
};
|
|
166
214
|
|
|
215
|
+
const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
|
|
216
|
+
const active = listSnapshot.selectedSessionKey === session.key;
|
|
217
|
+
const context = resolveSessionContextView(session, inputSnapshot.sessionTypeOptions);
|
|
218
|
+
const isEditing = editingSessionKey === session.key;
|
|
219
|
+
const isSaving = savingSessionKey === session.key;
|
|
220
|
+
return (
|
|
221
|
+
<ChatSidebarSessionItem
|
|
222
|
+
key={session.key}
|
|
223
|
+
session={session}
|
|
224
|
+
active={active}
|
|
225
|
+
runStatus={runStatus}
|
|
226
|
+
context={context}
|
|
227
|
+
title={sessionTitle(session)}
|
|
228
|
+
agentId={session.agentId ?? null}
|
|
229
|
+
agentLabel={session.agentId ? (agentsById.get(session.agentId)?.displayName ?? session.agentId) : null}
|
|
230
|
+
agentAvatarUrl={session.agentId ? (agentsById.get(session.agentId)?.avatarUrl ?? null) : null}
|
|
231
|
+
isEditing={isEditing}
|
|
232
|
+
draftLabel={draftLabel}
|
|
233
|
+
isSaving={isSaving}
|
|
234
|
+
onSelect={() => presenter.chatSessionListManager.selectSession(session.key)}
|
|
235
|
+
onStartEditing={() => startEditingSessionLabel(session)}
|
|
236
|
+
onDraftLabelChange={setDraftLabel}
|
|
237
|
+
onSave={() => saveSessionLabel(session)}
|
|
238
|
+
onCancel={cancelEditingSessionLabel}
|
|
239
|
+
/>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
167
243
|
return (
|
|
168
244
|
<aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
|
|
169
245
|
<div className="px-5 pt-5 pb-3">
|
|
@@ -272,9 +348,34 @@ export function ChatSidebar() {
|
|
|
272
348
|
|
|
273
349
|
<div className="mx-4 border-t border-gray-200/60" />
|
|
274
350
|
|
|
351
|
+
<div className="flex items-center justify-between px-5 pb-2 pt-3">
|
|
352
|
+
<div className="text-[11px] font-medium uppercase tracking-wider text-gray-400">
|
|
353
|
+
{t('chatSidebarTaskRecords')}
|
|
354
|
+
</div>
|
|
355
|
+
<ChatSidebarListModeSwitch
|
|
356
|
+
isProjectFirstView={isProjectFirstView}
|
|
357
|
+
onSelectMode={presenter.chatSessionListManager.setListMode}
|
|
358
|
+
/>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
275
361
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
|
|
276
362
|
{isLoading ? (
|
|
277
363
|
<div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
|
|
364
|
+
) : isProjectFirstView ? (
|
|
365
|
+
projectGroups.length === 0 ? (
|
|
366
|
+
<div className="p-4 text-center">
|
|
367
|
+
<MessageSquareText className="h-6 w-6 mx-auto mb-2 text-gray-300" />
|
|
368
|
+
<div className="text-xs text-gray-500">{t('chatSidebarProjectViewEmpty')}</div>
|
|
369
|
+
</div>
|
|
370
|
+
) : (
|
|
371
|
+
<ChatSidebarProjectGroups
|
|
372
|
+
groups={projectGroups}
|
|
373
|
+
defaultSessionType={defaultSessionType}
|
|
374
|
+
sessionTypeOptions={inputSnapshot.sessionTypeOptions}
|
|
375
|
+
renderSessionItem={renderSessionItem}
|
|
376
|
+
onCreateSession={presenter.chatSessionListManager.createSession}
|
|
377
|
+
/>
|
|
378
|
+
)
|
|
278
379
|
) : groups.length === 0 ? (
|
|
279
380
|
<div className="p-4 text-center">
|
|
280
381
|
<MessageSquareText className="h-6 w-6 mx-auto mb-2 text-gray-300" />
|
|
@@ -288,33 +389,7 @@ export function ChatSidebar() {
|
|
|
288
389
|
{group.label}
|
|
289
390
|
</div>
|
|
290
391
|
<div className="space-y-0.5">
|
|
291
|
-
{group.items.map(
|
|
292
|
-
const active = listSnapshot.selectedSessionKey === session.key;
|
|
293
|
-
const context = resolveSessionContextView(session, inputSnapshot.sessionTypeOptions);
|
|
294
|
-
const isEditing = editingSessionKey === session.key;
|
|
295
|
-
const isSaving = savingSessionKey === session.key;
|
|
296
|
-
return (
|
|
297
|
-
<ChatSidebarSessionItem
|
|
298
|
-
key={session.key}
|
|
299
|
-
session={session}
|
|
300
|
-
active={active}
|
|
301
|
-
runStatus={runStatus}
|
|
302
|
-
context={context}
|
|
303
|
-
title={sessionTitle(session)}
|
|
304
|
-
agentId={session.agentId ?? null}
|
|
305
|
-
agentLabel={session.agentId ? (agentsById.get(session.agentId)?.displayName ?? session.agentId) : null}
|
|
306
|
-
agentAvatarUrl={session.agentId ? (agentsById.get(session.agentId)?.avatarUrl ?? null) : null}
|
|
307
|
-
isEditing={isEditing}
|
|
308
|
-
draftLabel={draftLabel}
|
|
309
|
-
isSaving={isSaving}
|
|
310
|
-
onSelect={() => presenter.chatSessionListManager.selectSession(session.key)}
|
|
311
|
-
onStartEditing={() => startEditingSessionLabel(session)}
|
|
312
|
-
onDraftLabelChange={setDraftLabel}
|
|
313
|
-
onSave={() => saveSessionLabel(session)}
|
|
314
|
-
onCancel={cancelEditingSessionLabel}
|
|
315
|
-
/>
|
|
316
|
-
);
|
|
317
|
-
})}
|
|
392
|
+
{group.items.map(renderSessionItem)}
|
|
318
393
|
</div>
|
|
319
394
|
</div>
|
|
320
395
|
))}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { t } from '@/lib/i18n';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
type ChatSidebarListModeSwitchProps = {
|
|
5
|
+
isProjectFirstView: boolean;
|
|
6
|
+
onSelectMode: (mode: 'time-first' | 'project-first') => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ChatSidebarListModeSwitch(props: ChatSidebarListModeSwitchProps) {
|
|
10
|
+
const { isProjectFirstView, onSelectMode } = props;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex items-center gap-1.5 text-[11px]">
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
aria-pressed={!isProjectFirstView}
|
|
17
|
+
onClick={() => onSelectMode('time-first')}
|
|
18
|
+
className={cn(
|
|
19
|
+
'transition-colors',
|
|
20
|
+
isProjectFirstView
|
|
21
|
+
? 'text-gray-400 hover:text-gray-600'
|
|
22
|
+
: 'font-medium text-gray-600'
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{t('chatSidebarViewTime')}
|
|
26
|
+
</button>
|
|
27
|
+
<span className="text-gray-300">/</span>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
aria-pressed={isProjectFirstView}
|
|
31
|
+
onClick={() => onSelectMode('project-first')}
|
|
32
|
+
className={cn(
|
|
33
|
+
'transition-colors',
|
|
34
|
+
isProjectFirstView
|
|
35
|
+
? 'font-medium text-gray-600'
|
|
36
|
+
: 'text-gray-400 hover:text-gray-600'
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{t('chatSidebarViewProject')}
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Plus } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
5
|
+
import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
|
|
6
|
+
import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
|
|
7
|
+
import { t } from '@/lib/i18n';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
export type ChatSidebarProjectGroup = {
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
projectName: string;
|
|
13
|
+
items: NcpSessionListItemView[];
|
|
14
|
+
latestUpdatedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SessionTypeOption = ChatInputSnapshot['sessionTypeOptions'][number];
|
|
18
|
+
|
|
19
|
+
type ChatSidebarProjectGroupsProps = {
|
|
20
|
+
groups: ChatSidebarProjectGroup[];
|
|
21
|
+
defaultSessionType: string;
|
|
22
|
+
sessionTypeOptions: SessionTypeOption[];
|
|
23
|
+
renderSessionItem: (item: NcpSessionListItemView) => ReactNode;
|
|
24
|
+
onCreateSession: (sessionType: string, projectRoot: string) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function resolveProjectGroupDefaultSessionType(
|
|
28
|
+
defaultSessionType: string,
|
|
29
|
+
sessionTypeOptions: SessionTypeOption[]
|
|
30
|
+
): string {
|
|
31
|
+
if (sessionTypeOptions.some((option) => option.value === defaultSessionType)) {
|
|
32
|
+
return defaultSessionType;
|
|
33
|
+
}
|
|
34
|
+
return sessionTypeOptions[0]?.value ?? defaultSessionType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveSessionTypeStatusText(option: {
|
|
38
|
+
ready?: boolean;
|
|
39
|
+
reasonMessage?: string | null;
|
|
40
|
+
}): string {
|
|
41
|
+
if (option.ready === false) {
|
|
42
|
+
return option.reasonMessage?.trim() || t('statusSetup');
|
|
43
|
+
}
|
|
44
|
+
return t('statusReady');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
|
|
48
|
+
const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem, onCreateSession } = props;
|
|
49
|
+
const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
|
|
50
|
+
const preferredSessionType = useMemo(
|
|
51
|
+
() => resolveProjectGroupDefaultSessionType(defaultSessionType, sessionTypeOptions),
|
|
52
|
+
[defaultSessionType, sessionTypeOptions]
|
|
53
|
+
);
|
|
54
|
+
const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-3">
|
|
58
|
+
{groups.map((group) => {
|
|
59
|
+
const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div key={group.projectRoot}>
|
|
63
|
+
<div className="flex items-center justify-between gap-2 px-2 py-0.5">
|
|
64
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
65
|
+
<div
|
|
66
|
+
className="truncate text-[11px] font-medium uppercase tracking-wider text-gray-500"
|
|
67
|
+
title={group.projectRoot}
|
|
68
|
+
>
|
|
69
|
+
{group.projectName}
|
|
70
|
+
</div>
|
|
71
|
+
<span className="shrink-0 text-[10px] text-gray-400">
|
|
72
|
+
{group.items.length}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
{supportsSessionTypeChoice ? (
|
|
76
|
+
<Popover
|
|
77
|
+
open={openProjectRoot === group.projectRoot}
|
|
78
|
+
onOpenChange={(nextOpen) => {
|
|
79
|
+
setOpenProjectRoot(nextOpen ? group.projectRoot : null);
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<PopoverTrigger asChild>
|
|
83
|
+
<Button
|
|
84
|
+
type="button"
|
|
85
|
+
variant="ghost"
|
|
86
|
+
size="icon"
|
|
87
|
+
className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
|
|
88
|
+
aria-label={actionLabel}
|
|
89
|
+
title={actionLabel}
|
|
90
|
+
>
|
|
91
|
+
<Plus className="h-3.5 w-3.5" />
|
|
92
|
+
</Button>
|
|
93
|
+
</PopoverTrigger>
|
|
94
|
+
<PopoverContent align="end" className="w-64 p-2">
|
|
95
|
+
<div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
|
|
96
|
+
{t('chatSessionTypeLabel')}
|
|
97
|
+
</div>
|
|
98
|
+
<div className="mt-1 space-y-1">
|
|
99
|
+
{sessionTypeOptions.map((option) => (
|
|
100
|
+
<button
|
|
101
|
+
key={`${group.projectRoot}:${option.value}`}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
onCreateSession(option.value, group.projectRoot);
|
|
105
|
+
setOpenProjectRoot(null);
|
|
106
|
+
}}
|
|
107
|
+
className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
|
|
108
|
+
>
|
|
109
|
+
<div className="flex items-center justify-between gap-3">
|
|
110
|
+
<div className="text-[13px] font-medium text-gray-900">{option.label}</div>
|
|
111
|
+
<span
|
|
112
|
+
className={cn(
|
|
113
|
+
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
|
114
|
+
option.ready === false
|
|
115
|
+
? 'bg-amber-100 text-amber-800'
|
|
116
|
+
: 'bg-emerald-100 text-emerald-700'
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{option.ready === false ? t('statusSetup') : t('statusReady')}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="mt-0.5 text-[11px] text-gray-500">
|
|
123
|
+
{resolveSessionTypeStatusText(option)}
|
|
124
|
+
</div>
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</PopoverContent>
|
|
129
|
+
</Popover>
|
|
130
|
+
) : (
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="icon"
|
|
135
|
+
className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
|
|
136
|
+
onClick={() => onCreateSession(preferredSessionType, group.projectRoot)}
|
|
137
|
+
aria-label={actionLabel}
|
|
138
|
+
title={actionLabel}
|
|
139
|
+
>
|
|
140
|
+
<Plus className="h-3.5 w-3.5" />
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
<div className="space-y-0.5 pl-2">
|
|
145
|
+
{group.items.map(renderSessionItem)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -9,13 +9,16 @@ describe('ChatSessionListManager', () => {
|
|
|
9
9
|
snapshot: {
|
|
10
10
|
...useChatInputStore.getState().snapshot,
|
|
11
11
|
defaultSessionType: 'native',
|
|
12
|
-
pendingSessionType: 'native'
|
|
12
|
+
pendingSessionType: 'native',
|
|
13
|
+
pendingProjectRoot: null,
|
|
14
|
+
pendingProjectRootSessionKey: null
|
|
13
15
|
}
|
|
14
16
|
});
|
|
15
17
|
useChatSessionListStore.setState({
|
|
16
18
|
snapshot: {
|
|
17
19
|
...useChatSessionListStore.getState().snapshot,
|
|
18
|
-
selectedSessionKey: 'session-1'
|
|
20
|
+
selectedSessionKey: 'session-1',
|
|
21
|
+
listMode: 'time-first'
|
|
19
22
|
}
|
|
20
23
|
});
|
|
21
24
|
});
|
|
@@ -33,8 +36,25 @@ describe('ChatSessionListManager', () => {
|
|
|
33
36
|
|
|
34
37
|
expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
|
|
35
38
|
expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
|
|
36
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).
|
|
39
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
37
40
|
expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
|
|
41
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
|
|
42
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('hydrates the draft project root when creating a session inside a project group', () => {
|
|
46
|
+
const uiManager = {
|
|
47
|
+
goToChatRoot: vi.fn()
|
|
48
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
49
|
+
const streamActionsManager = {
|
|
50
|
+
resetStreamState: vi.fn()
|
|
51
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
52
|
+
|
|
53
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
54
|
+
manager.createSession('native', '/tmp/project-alpha');
|
|
55
|
+
|
|
56
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
|
|
57
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
|
|
38
58
|
});
|
|
39
59
|
|
|
40
60
|
it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
|
|
@@ -51,4 +71,15 @@ describe('ChatSessionListManager', () => {
|
|
|
51
71
|
expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
|
|
52
72
|
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
53
73
|
});
|
|
74
|
+
|
|
75
|
+
it('updates the sidebar list mode without touching other session list state', () => {
|
|
76
|
+
const uiManager = {} as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
77
|
+
const streamActionsManager = {} as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
78
|
+
|
|
79
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
80
|
+
manager.setListMode('project-first');
|
|
81
|
+
|
|
82
|
+
expect(useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
|
|
83
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
84
|
+
});
|
|
54
85
|
});
|