@nextclaw/ui 0.12.3 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/assets/{ChannelsList-DZWam3Ob.js → ChannelsList-CobWeI2V.js} +1 -1
  3. package/dist/assets/ChatPage-ZIdFFVAv.js +43 -0
  4. package/dist/assets/DocBrowser-D55C0iyl.js +1 -0
  5. package/dist/assets/{DocBrowser-C7-1sXqo.js → DocBrowser-NSzgVKka.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-DpgVdRgk.js} +1 -1
  7. package/dist/assets/{LogoBadge-DDS1sU_U.js → LogoBadge-CHS4YNLw.js} +1 -1
  8. package/dist/assets/{MarketplacePage-2tWWgwAb.js → MarketplacePage-BFYsRss_.js} +1 -1
  9. package/dist/assets/MarketplacePage-DII-q-Y1.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-N-fB4HID.js → McpMarketplacePage-CPqsGJzz.js} +1 -1
  11. package/dist/assets/{ModelConfig-DvsBTUiE.js → ModelConfig-Bvuo_IpS.js} +1 -1
  12. package/dist/assets/{ProviderScopedModelInput-D9woCARc.js → ProviderScopedModelInput-BfY8rGsf.js} +1 -1
  13. package/dist/assets/{ProvidersList-D-qPGgC4.js → ProvidersList-3tlaqwSS.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-COnjm8_x.js → RemoteAccessPage-yfbrveNQ.js} +1 -1
  15. package/dist/assets/{RuntimeConfig-BHpqcaHm.js → RuntimeConfig-CAd5Kta3.js} +1 -1
  16. package/dist/assets/{SearchConfig-DIT6M65Q.js → SearchConfig-DFwgaAa7.js} +1 -1
  17. package/dist/assets/{SecretsConfig-Cefg1LFJ.js → SecretsConfig-CLFSSoTl.js} +1 -1
  18. package/dist/assets/{SessionsConfig-BZnmVTIu.js → SessionsConfig-vYrvc2Fk.js} +1 -1
  19. package/dist/assets/{book-open-DvWqOode.js → book-open-C7TAghTk.js} +1 -1
  20. package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-5dVFkJyw.js} +1 -1
  21. package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-DbL4EmiT.js} +1 -1
  22. package/dist/assets/{config-BeGwf2Ao.js → config-CMiW0yaK.js} +1 -1
  23. package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-BRLFtf-8.js} +1 -1
  24. package/dist/assets/{dist-RWNFhxvR.js → dist-BFc_H-lY.js} +1 -1
  25. package/dist/assets/{dist-B6VMuIQN.js → dist-DP-JKR4G.js} +1 -1
  26. package/dist/assets/{external-link-U86Acd1t.js → external-link-BkJkiWbH.js} +1 -1
  27. package/dist/assets/{hash-D-OVfV3Z.js → hash-CbP6-6R9.js} +1 -1
  28. package/dist/assets/i18n-C_2dKw6w.js +1 -0
  29. package/dist/assets/index-ChUXhq0G.css +1 -0
  30. package/dist/assets/{index-DHmCjcxq.js → index-DAE8Srx-.js} +3 -3
  31. package/dist/assets/{label-CHJ1ATds.js → label-D8yyejJS.js} +1 -1
  32. package/dist/assets/loader-circle-B0sKKO29.js +1 -0
  33. package/dist/assets/{logos-U1_qDA3U.js → logos-N3dbS6-I.js} +1 -1
  34. package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-DyuvlNrg.js} +1 -1
  35. package/dist/assets/plus-CYXs3JtZ.js +1 -0
  36. package/dist/assets/{popover-xWbqMnIN.js → popover-BKKWGUaG.js} +1 -1
  37. package/dist/assets/{react-3YE87-lE.js → react-8EIEQjMP.js} +1 -1
  38. package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-BGMdiNGq.js} +1 -1
  39. package/dist/assets/{save-4VRlzkii.js → save-Dh4GQzzX.js} +1 -1
  40. package/dist/assets/search-DOsLw-P9.js +1 -0
  41. package/dist/assets/{security-config-DEgOD4VX.js → security-config-CM_tQRXQ.js} +1 -1
  42. package/dist/assets/{select-DF-AUoie.js → select-BtIi5fnh.js} +1 -1
  43. package/dist/assets/skeleton-GbHLjPC0.js +1 -0
  44. package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-C4O-2jZP.js} +1 -1
  45. package/dist/assets/{switch-D7JF_RZ-.js → switch-DPegGIa_.js} +1 -1
  46. package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-x5GZexrF.js} +1 -1
  47. package/dist/assets/{trash-2-VV8jvziy.js → trash-2-CU3LYIpQ.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-CuQqiPx7.js → useConfirmDialog-S5WsGOGf.js} +1 -1
  49. package/dist/assets/{useMutation-DBTWPbTg.js → useMutation-DSinpgEq.js} +1 -1
  50. package/dist/assets/x-Bnco_K8b.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +5 -5
  53. package/src/components/chat/ChatSidebar.test.tsx +168 -28
  54. package/src/components/chat/ChatSidebar.tsx +103 -28
  55. package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
  56. package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
  57. package/src/components/chat/managers/chat-session-list.manager.test.ts +4 -4
  58. package/src/components/chat/managers/chat-session-list.manager.ts +3 -4
  59. package/src/components/chat/ncp/NcpChatPage.tsx +18 -4
  60. package/src/components/chat/ncp/ncp-chat.presenter.ts +1 -5
  61. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
  62. package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
  63. package/src/lib/i18n.chat.ts +3 -0
  64. package/dist/assets/ChatPage-YBL7iJ1X.js +0 -43
  65. package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
  66. package/dist/assets/MarketplacePage-BorWJftJ.js +0 -1
  67. package/dist/assets/i18n-hM3v-3YG.js +0 -1
  68. package/dist/assets/index-CpxuJa9o.css +0 -1
  69. package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
  70. package/dist/assets/plus-CrkO1kob.js +0 -1
  71. package/dist/assets/search-EX-Papzl.js +0 -1
  72. package/dist/assets/skeleton-B0mmt1vo.js +0 -1
  73. package/dist/assets/x-B4sxJkGY.js +0 -1
@@ -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 groups = useMemo(() => groupSessionsByDate(items), [items]);
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(({ session, runStatus }) => {
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
+ }
@@ -31,12 +31,12 @@ describe('ChatSessionListManager', () => {
31
31
  resetStreamState: vi.fn()
32
32
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
33
33
 
34
- const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-1');
34
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
35
35
  manager.createSession('codex');
36
36
 
37
37
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
38
38
  expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
39
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
39
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
40
40
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
41
41
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
42
42
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
@@ -50,11 +50,11 @@ describe('ChatSessionListManager', () => {
50
50
  resetStreamState: vi.fn()
51
51
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
52
52
 
53
- const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-9');
53
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
54
54
  manager.createSession('native', '/tmp/project-alpha');
55
55
 
56
56
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
57
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-session-9');
57
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
58
58
  });
59
59
 
60
60
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
@@ -8,8 +8,7 @@ import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-
8
8
  export class ChatSessionListManager {
9
9
  constructor(
10
10
  private uiManager: ChatUiManager,
11
- private streamActionsManager: ChatStreamActionsManager,
12
- private getDraftSessionId: () => string = () => ''
11
+ private streamActionsManager: ChatStreamActionsManager
13
12
  ) {}
14
13
 
15
14
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
@@ -55,12 +54,12 @@ export class ChatSessionListManager {
55
54
  ? sessionType.trim()
56
55
  : defaultSessionType;
57
56
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
58
- const draftSessionId = normalizedProjectRoot ? this.getDraftSessionId() : null;
59
57
  this.streamActionsManager.resetStreamState();
58
+ useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
60
59
  useChatInputStore.getState().setSnapshot({
61
60
  pendingSessionType: nextSessionType,
62
61
  pendingProjectRoot: normalizedProjectRoot,
63
- pendingProjectRootSessionKey: draftSessionId
62
+ pendingProjectRootSessionKey: null
64
63
  });
65
64
  this.uiManager.goToChatRoot();
66
65
  };
@@ -131,8 +131,13 @@ export function NcpChatPage({ view }: ChatPageProps) {
131
131
  [routeSessionIdParam],
132
132
  );
133
133
  const sessionKey = selectedSessionKey ?? draftSessionId;
134
+ const hasDraftProjectRootOverride =
135
+ pendingProjectRoot !== null &&
136
+ pendingProjectRootSessionKey === null &&
137
+ selectedSessionKey === null;
134
138
  const hasSessionProjectRootOverride =
135
- pendingProjectRootSessionKey === sessionKey;
139
+ pendingProjectRoot !== null &&
140
+ (pendingProjectRootSessionKey === sessionKey || hasDraftProjectRootOverride);
136
141
  const sessionProjectRootOverride = hasSessionProjectRootOverride
137
142
  ? pendingProjectRoot
138
143
  : undefined;
@@ -210,7 +215,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
210
215
  thinkingLevel: payload.thinkingLevel,
211
216
  sessionType: payload.sessionType,
212
217
  projectRoot:
213
- payload.sessionKey === pendingProjectRootSessionKey
218
+ payload.sessionKey === pendingProjectRootSessionKey ||
219
+ (pendingProjectRoot !== null &&
220
+ pendingProjectRootSessionKey === null &&
221
+ selectedSessionKey === null)
214
222
  ? pendingProjectRoot
215
223
  : (selectedSession?.projectRoot ?? null),
216
224
  requestedSkills: payload.requestedSkills,
@@ -265,14 +273,20 @@ export function NcpChatPage({ view }: ChatPageProps) {
265
273
  pendingProjectRoot,
266
274
  pendingProjectRootSessionKey,
267
275
  presenter,
276
+ selectedSessionKey,
268
277
  selectedSession?.projectRoot,
269
278
  sessionKey,
270
279
  ]);
271
280
 
272
281
  useEffect(() => {
282
+ const matchesPendingProjectSession =
283
+ pendingProjectRootSessionKey === null
284
+ ? selectedSessionKey !== null
285
+ : pendingProjectRootSessionKey === selectedSession?.key;
273
286
  if (
274
287
  !selectedSession ||
275
- pendingProjectRootSessionKey !== selectedSession.key ||
288
+ pendingProjectRoot === null ||
289
+ !matchesPendingProjectSession ||
276
290
  (selectedSession.projectRoot ?? null) !== pendingProjectRoot
277
291
  ) {
278
292
  return;
@@ -281,7 +295,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
281
295
  pendingProjectRoot: null,
282
296
  pendingProjectRootSessionKey: null,
283
297
  });
284
- }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession]);
298
+ }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession, selectedSessionKey]);
285
299
 
286
300
  useChatSessionSync({
287
301
  view,
@@ -7,11 +7,7 @@ import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.mana
7
7
  export class NcpChatPresenter {
8
8
  chatUiManager = new ChatUiManager();
9
9
  chatStreamActionsManager = new ChatStreamActionsManager();
10
- chatSessionListManager = new ChatSessionListManager(
11
- this.chatUiManager,
12
- this.chatStreamActionsManager,
13
- () => this.getDraftSessionId()
14
- );
10
+ chatSessionListManager = new ChatSessionListManager(this.chatUiManager, this.chatStreamActionsManager);
15
11
  chatInputManager = new NcpChatInputManager(
16
12
  this.chatUiManager,
17
13
  this.chatStreamActionsManager,
@@ -40,6 +40,22 @@ describe('ChatSessionProjectBadge', () => {
40
40
  expect(screen.getByText('/tmp/project-alpha')).toBeTruthy();
41
41
  });
42
42
 
43
+ it('uses the neutral header tag styling instead of a highlighted accent color', () => {
44
+ render(
45
+ <ChatSessionProjectBadge
46
+ sessionKey="session-1"
47
+ projectName="project-alpha"
48
+ projectRoot="/tmp/project-alpha"
49
+ persistToServer
50
+ />
51
+ );
52
+
53
+ const trigger = screen.getByRole('button', { name: 'Set Project Directory' });
54
+ expect(trigger.className).toContain('border-gray-200');
55
+ expect(trigger.className).toContain('text-gray-600');
56
+ expect(trigger.className).not.toContain('emerald');
57
+ });
58
+
43
59
  it('clears the current project from the badge popover', async () => {
44
60
  const user = userEvent.setup();
45
61
 
@@ -46,7 +46,7 @@ export function ChatSessionProjectBadge({
46
46
  <button
47
47
  type="button"
48
48
  title={projectRoot ?? undefined}
49
- className="min-w-0 max-w-[320px] shrink rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 transition-colors hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
49
+ className="min-w-0 max-w-[320px] shrink rounded-full border border-gray-200 bg-gray-100/90 px-2 py-0.5 text-[11px] font-medium text-gray-600 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60"
50
50
  aria-label={t('chatSessionSetProject')}
51
51
  disabled={isProjectPending}
52
52
  >
@@ -59,7 +59,7 @@ export function ChatSessionProjectBadge({
59
59
  </PopoverTrigger>
60
60
  <PopoverContent align="start" className="w-72 p-2">
61
61
  <div className="px-3 pb-2 pt-1">
62
- <div className="text-[11px] font-medium uppercase tracking-wider text-emerald-700/80">
62
+ <div className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
63
63
  {projectName}
64
64
  </div>
65
65
  {projectRoot ? (
@@ -125,10 +125,13 @@ export const CHAT_LABELS: Record<string, { zh: string; en: string }> = {
125
125
  chatSidebarScheduledTasks: { zh: '定时任务', en: 'Scheduled Tasks' },
126
126
  chatSidebarSkills: { zh: '技能', en: 'Skills' },
127
127
  chatSidebarTaskRecords: { zh: '会话记录', en: 'Sessions' },
128
+ chatSidebarViewTime: { zh: '时间', en: 'Time' },
129
+ chatSidebarViewProject: { zh: '项目', en: 'Project' },
128
130
  chatSidebarToday: { zh: '今天', en: 'Today' },
129
131
  chatSidebarYesterday: { zh: '昨天', en: 'Yesterday' },
130
132
  chatSidebarPrevious7Days: { zh: '近 7 天', en: 'Previous 7 Days' },
131
133
  chatSidebarOlder: { zh: '更早', en: 'Older' },
134
+ chatSidebarProjectViewEmpty: { zh: '还没有绑定项目的会话', en: 'No project conversations yet' },
132
135
  chatWelcomeTitle: { zh: '你好,有什么可以帮你的吗?', en: 'Hello, how can I help you?' },
133
136
  chatWelcomeSubtitle: { zh: '开始一个新任务或选择已有对话', en: 'Start a new task or select an existing conversation' },
134
137
  chatWelcomeCapability1Title: { zh: '智能对话', en: 'Smart Conversations' },