@nextclaw/ui 0.10.5 → 0.11.1

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 (89) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/dist/assets/{ChannelsList-Nu7Ig6_-.js → ChannelsList-CVPqrxns.js} +4 -4
  3. package/dist/assets/ChatPage-BO1VUrAY.js +37 -0
  4. package/dist/assets/{DocBrowser-3CfKmJA6.js → DocBrowser-FBwg8iji.js} +1 -1
  5. package/dist/assets/{LogoBadge-DdthDJOp.js → LogoBadge-BCmJfRT8.js} +1 -1
  6. package/dist/assets/MarketplacePage-DWxXUOCx.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-Dg8GSZh6.js → McpMarketplacePage-Bth9X_hu.js} +2 -2
  8. package/dist/assets/{ModelConfig-DyQ6cC92.js → ModelConfig-PkSp_ioc.js} +1 -1
  9. package/dist/assets/ProvidersList-DVDge8wa.js +1 -0
  10. package/dist/assets/RemoteAccessPage-BVkzfEaL.js +1 -0
  11. package/dist/assets/RuntimeConfig-ByJs3khh.js +1 -0
  12. package/dist/assets/{SearchConfig-R1BcCLWO.js → SearchConfig-KZUAqYJN.js} +1 -1
  13. package/dist/assets/{SecretsConfig-D-jZMHeY.js → SecretsConfig-qwB_Y_Ka.js} +2 -2
  14. package/dist/assets/SessionsConfig-CGCl4UTr.js +2 -0
  15. package/dist/assets/index-CrilScMo.css +1 -0
  16. package/dist/assets/{index-BulnQWr6.js → index-D41ntvb7.js} +6 -6
  17. package/dist/assets/{label-C7yzBvzK.js → label-7JEFhkur.js} +1 -1
  18. package/dist/assets/ncp-session-adapter-BOqhkrc-.js +1 -0
  19. package/dist/assets/{page-layout-DF0xpax2.js → page-layout-B7q511TE.js} +1 -1
  20. package/dist/assets/popover-CywJGmPr.js +1 -0
  21. package/dist/assets/security-config-zi2UxN5r.js +1 -0
  22. package/dist/assets/skeleton-qUJZQ03S.js +1 -0
  23. package/dist/assets/{status-dot-B9opOZ22.js → status-dot-BilwNdTT.js} +1 -1
  24. package/dist/assets/{switch-l1P0ev4D.js → switch-BLp2Pno1.js} +1 -1
  25. package/dist/assets/tabs-custom-CgIdQMGC.js +1 -0
  26. package/dist/assets/useConfirmDialog-BitswAkv.js +1 -0
  27. package/dist/assets/{vendor-CNhxtHCf.js → vendor-D_JxmsLV.js} +87 -87
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/App.test.tsx +42 -10
  31. package/src/App.tsx +5 -40
  32. package/src/api/api-base.test.ts +37 -0
  33. package/src/api/api-base.ts +0 -4
  34. package/src/api/config.ts +2 -270
  35. package/src/api/ncp-attachments.ts +12 -12
  36. package/src/api/types.ts +4 -121
  37. package/src/components/chat/ChatPage.tsx +1 -11
  38. package/src/components/chat/ChatSidebar.test.tsx +1 -50
  39. package/src/components/chat/ChatSidebar.tsx +0 -5
  40. package/src/components/chat/README.md +2 -0
  41. package/src/components/chat/adapters/chat-message.adapter.test.ts +39 -0
  42. package/src/components/chat/adapters/chat-message.adapter.ts +56 -0
  43. package/src/components/chat/chat-attachment-upload-limit.test.ts +41 -0
  44. package/src/components/chat/chat-composer-state.test.ts +4 -4
  45. package/src/components/chat/chat-composer-state.ts +1 -1
  46. package/src/components/chat/chat-session-display.ts +9 -0
  47. package/src/components/chat/chat-session-label.service.ts +3 -12
  48. package/src/components/chat/chat-session-preference-sync.test.ts +10 -13
  49. package/src/components/chat/chat-stream/types.ts +4 -57
  50. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -2
  51. package/src/components/chat/ncp/NcpChatPage.tsx +3 -3
  52. package/src/components/chat/ncp/ncp-chat-input.manager.ts +1 -1
  53. package/src/components/chat/useHydratedNcpAgent.test.tsx +77 -0
  54. package/src/components/config/README.md +2 -0
  55. package/src/components/config/SessionsConfig.tsx +152 -132
  56. package/src/hooks/use-auth.test.ts +3 -3
  57. package/src/hooks/use-auth.ts +16 -4
  58. package/src/hooks/use-realtime-query-bridge.ts +0 -24
  59. package/src/hooks/useConfig.ts +10 -137
  60. package/src/lib/session-run-status.ts +1 -63
  61. package/src/vite-env.d.ts +1 -0
  62. package/vite.config.ts +4 -4
  63. package/dist/assets/ChatPage-CBCFSk4e.js +0 -38
  64. package/dist/assets/MarketplacePage-inGGiv1T.js +0 -49
  65. package/dist/assets/ProvidersList-B2T8Lc_i.js +0 -1
  66. package/dist/assets/RemoteAccessPage-C9LxgK-C.js +0 -1
  67. package/dist/assets/RuntimeConfig-Ey4VIqTW.js +0 -1
  68. package/dist/assets/SessionsConfig-Cawoh4_2.js +0 -2
  69. package/dist/assets/chat-message-BbuIK4dQ.js +0 -3
  70. package/dist/assets/index-kaPUhd-8.css +0 -1
  71. package/dist/assets/popover-DjaScZDJ.js +0 -1
  72. package/dist/assets/security-config-Bg2eriNx.js +0 -1
  73. package/dist/assets/skeleton-DycBJAJF.js +0 -1
  74. package/dist/assets/tabs-custom-BG9y2JhC.js +0 -1
  75. package/dist/assets/useConfirmDialog-DTducNfn.js +0 -1
  76. package/src/api/config.stream.test.ts +0 -115
  77. package/src/components/chat/chat-chain.test.ts +0 -22
  78. package/src/components/chat/chat-chain.ts +0 -23
  79. package/src/components/chat/chat-page-data.ts +0 -171
  80. package/src/components/chat/chat-page-runtime.ts +0 -190
  81. package/src/components/chat/chat-stream/nextbot-parsers.ts +0 -52
  82. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +0 -413
  83. package/src/components/chat/chat-stream/stream-event-adapter.ts +0 -98
  84. package/src/components/chat/chat-stream/transport.ts +0 -253
  85. package/src/components/chat/legacy/LegacyChatPage.tsx +0 -223
  86. package/src/components/chat/managers/chat-input.manager.ts +0 -228
  87. package/src/components/chat/managers/chat-thread.manager.ts +0 -87
  88. package/src/components/chat/presenter/chat.presenter.ts +0 -32
  89. package/src/components/chat/useChatRuntimeController.ts +0 -134
@@ -4,7 +4,7 @@ import {
4
4
  DEFAULT_NCP_ATTACHMENT_MAX_BYTES,
5
5
  uploadFilesAsNcpDraftAttachments
6
6
  } from '@nextclaw/ncp-react';
7
- import { uploadNcpAttachments } from '@/api/ncp-attachments';
7
+ import { uploadNcpAssets } from '@/api/ncp-attachments';
8
8
  import {
9
9
  buildChatSlashItems,
10
10
  buildModelStateHint,
@@ -150,7 +150,7 @@ export function ChatInputBarContainer() {
150
150
  return;
151
151
  }
152
152
  const result = await uploadFilesAsNcpDraftAttachments(files, {
153
- uploadBatch: uploadNcpAttachments,
153
+ uploadBatch: uploadNcpAssets,
154
154
  });
155
155
  if (result.attachments.length > 0) {
156
156
  const insertedAttachments = presenter.chatInputManager.addAttachments?.(result.attachments) ?? [];
@@ -8,15 +8,15 @@ import {
8
8
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
9
9
  import { API_BASE } from '@/api/api-base';
10
10
  import { fetchNcpSessionMessages } from '@/api/ncp-session';
11
- import type { ChatRunView } from '@/api/types';
12
- import { sessionDisplayName } from '@/components/chat/chat-page-data';
13
11
  import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
12
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
14
13
  import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
15
14
  import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
16
15
  import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
17
16
  import { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
18
17
  import { adaptNcpMessagesToUiMessages, buildNcpSessionRunStatusByKey, createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
19
18
  import { ChatPresenterProvider } from '@/components/chat/presenter/chat-presenter-context';
19
+ import type { ResumeRunParams } from '@/components/chat/chat-stream/types';
20
20
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
21
21
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
22
22
  import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
@@ -224,7 +224,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
224
224
  await agent.abort();
225
225
  await sessionsQuery.refetch();
226
226
  },
227
- resumeRun: async (run: ChatRunView) => {
227
+ resumeRun: async (run: ResumeRunParams) => {
228
228
  if (run.sessionKey !== activeSessionId) {
229
229
  return;
230
230
  }
@@ -27,7 +27,7 @@ export class NcpChatInputManager {
27
27
 
28
28
  private buildAttachmentSignature = (attachment: NcpDraftAttachment): string =>
29
29
  [
30
- attachment.attachmentUri ?? '',
30
+ attachment.assetUri ?? '',
31
31
  attachment.url ?? '',
32
32
  attachment.name,
33
33
  attachment.mimeType,
@@ -0,0 +1,77 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { useHydratedNcpAgent } from '../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ manager: {
7
+ reset: vi.fn(),
8
+ hydrate: vi.fn()
9
+ },
10
+ runtime: {
11
+ snapshot: {
12
+ messages: [],
13
+ streamingMessage: null,
14
+ activeRun: null,
15
+ error: null
16
+ },
17
+ visibleMessages: [],
18
+ activeRunId: null,
19
+ isRunning: false,
20
+ isSending: false,
21
+ send: vi.fn(),
22
+ abort: vi.fn(),
23
+ streamRun: vi.fn()
24
+ }
25
+ }));
26
+
27
+ vi.mock('../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js', () => ({
28
+ useScopedAgentManager: () => mocks.manager,
29
+ useNcpAgentRuntime: () => mocks.runtime
30
+ }));
31
+
32
+ describe('useHydratedNcpAgent', () => {
33
+ beforeEach(() => {
34
+ mocks.manager.reset.mockReset();
35
+ mocks.manager.hydrate.mockReset();
36
+ mocks.runtime.send.mockReset();
37
+ mocks.runtime.abort.mockReset();
38
+ mocks.runtime.streamRun.mockReset();
39
+ });
40
+
41
+ it('treats a newly selected session as hydrating immediately on rerender', async () => {
42
+ const client = {
43
+ stop: vi.fn().mockResolvedValue(undefined),
44
+ stream: vi.fn().mockResolvedValue(undefined)
45
+ } as never;
46
+ const loadSeed = vi
47
+ .fn()
48
+ .mockResolvedValueOnce({ messages: [], status: 'idle' })
49
+ .mockResolvedValueOnce({ messages: [], status: 'idle' });
50
+
51
+ const { result, rerender } = renderHook(
52
+ ({ sessionId }: { sessionId: string }) =>
53
+ useHydratedNcpAgent({
54
+ sessionId,
55
+ client,
56
+ loadSeed
57
+ }),
58
+ {
59
+ initialProps: {
60
+ sessionId: 'session-a'
61
+ }
62
+ }
63
+ );
64
+
65
+ await waitFor(() => {
66
+ expect(result.current.isHydrating).toBe(false);
67
+ });
68
+
69
+ rerender({ sessionId: 'session-b' });
70
+
71
+ expect(result.current.isHydrating).toBe(true);
72
+
73
+ await waitFor(() => {
74
+ expect(result.current.isHydrating).toBe(false);
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,2 @@
1
+ ## 目录预算豁免
2
+ - 原因:配置中心目录按配置面板维度组织,每个面板都需要独立入口、局部测试与装配文件;当前结构受 UI 信息架构约束,需要保留超过 `20` 个直接代码文件的扁平集合。
@@ -1,19 +1,15 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
- import type { SessionEntryView, SessionMessageView } from '@/api/types';
2
+ import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView } from '@/api/types';
3
3
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
4
- import { useChatRuns, useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
4
+ import { useDeleteNcpSession, useNcpSessionMessages, useNcpSessions, useUpdateNcpSession } from '@/hooks/useConfig';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
8
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
9
+ import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
10
+ import { sessionDisplayName } from '@/components/chat/chat-session-display';
9
11
  import { cn } from '@/lib/utils';
10
12
  import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
11
- import { extractMessageText } from '@/lib/chat-message';
12
- import {
13
- buildActiveRunBySessionKey,
14
- buildSessionRunStatusByKey,
15
- type SessionRunStatus
16
- } from '@/lib/session-run-status';
17
13
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
18
14
  import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
19
15
 
@@ -39,83 +35,107 @@ function displayChannelName(channel: string): string {
39
35
  return channel;
40
36
  }
41
37
 
42
- // ============================================================================
43
- // COMPONENT: Left Sidebar Session Item
44
- // ============================================================================
38
+ function normalizeNcpRole(role: NcpMessageView['role']): 'user' | 'assistant' | 'system' | 'tool' {
39
+ if (role === 'service') {
40
+ return 'system';
41
+ }
42
+ if (role === 'tool') {
43
+ return 'tool';
44
+ }
45
+ return role;
46
+ }
47
+
48
+ function extractNcpMessageText(message: NcpMessageView): string {
49
+ const parts: string[] = [];
50
+ for (const part of message.parts) {
51
+ if (part.type === 'text' || part.type === 'rich-text' || part.type === 'reasoning') {
52
+ parts.push(part.text);
53
+ continue;
54
+ }
55
+ if (part.type === 'tool-invocation') {
56
+ const prefix = part.toolName?.trim() ? `[${part.toolName.trim()}]` : '[tool]';
57
+ if (part.state === 'result' && typeof part.result === 'string' && part.result.trim()) {
58
+ parts.push(`${prefix} ${part.result.trim()}`);
59
+ continue;
60
+ }
61
+ parts.push(prefix);
62
+ }
63
+ }
64
+ return parts.join('\n').trim();
65
+ }
45
66
 
46
67
  type SessionListItemProps = {
47
68
  session: SessionEntryView;
69
+ summary: NcpSessionSummaryView;
48
70
  channel: string;
49
- runStatus?: SessionRunStatus;
50
71
  isSelected: boolean;
51
72
  onSelect: () => void;
52
73
  };
53
74
 
54
- function SessionListItem({ session, channel, runStatus, isSelected, onSelect }: SessionListItemProps) {
75
+ function SessionListItem({ session, summary, channel, isSelected, onSelect }: SessionListItemProps) {
55
76
  const channelDisplay = displayChannelName(channel);
56
- const displayName = session.label || session.key.split(':').pop() || session.key;
77
+ const displayName = sessionDisplayName(session);
57
78
 
58
79
  return (
59
80
  <button
60
81
  onClick={onSelect}
61
82
  className={cn(
62
- "w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group",
83
+ 'w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group',
63
84
  isSelected
64
- ? "bg-brand-50 border border-brand-100/50"
65
- : "bg-transparent border border-transparent hover:bg-gray-50/80"
85
+ ? 'bg-brand-50 border border-brand-100/50'
86
+ : 'bg-transparent border border-transparent hover:bg-gray-50/80'
66
87
  )}
67
88
  >
68
89
  <div className="flex items-start justify-between mb-1.5">
69
- <div className={cn("font-semibold truncate pr-2 flex-1 text-sm", isSelected ? "text-brand-800" : "text-gray-900")}>
90
+ <div className={cn('font-semibold truncate pr-2 flex-1 text-sm', isSelected ? 'text-brand-800' : 'text-gray-900')}>
70
91
  {displayName}
71
92
  </div>
72
- <div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize", isSelected ? "bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]" : "bg-gray-100 text-gray-500")}>
93
+ <div className={cn('text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize', isSelected ? 'bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]' : 'bg-gray-100 text-gray-500')}>
73
94
  {channelDisplay}
74
95
  </div>
75
96
  </div>
76
97
 
77
- <div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
98
+ <div className={cn('flex items-center text-xs justify-between mt-2 font-medium', isSelected ? 'text-brand-600/80' : 'text-gray-400')}>
78
99
  <div className="flex items-center gap-1.5">
79
100
  <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
80
- {runStatus ? <SessionRunBadge status={runStatus} /> : null}
101
+ {summary.status === 'running' ? <SessionRunBadge status="running" /> : null}
81
102
  </span>
82
103
  <Clock className="w-3.5 h-3.5 opacity-70" />
83
- <span className="truncate max-w-[100px]">{formatDateShort(session.updatedAt)}</span>
104
+ <span className="truncate max-w-[100px]">{formatDateShort(summary.updatedAt)}</span>
84
105
  </div>
85
106
  <div className="flex items-center gap-1">
86
107
  <MessageCircle className="w-3.5 h-3.5 opacity-70" />
87
- <span>{session.messageCount}</span>
108
+ <span>{summary.messageCount}</span>
88
109
  </div>
89
110
  </div>
90
111
  </button>
91
112
  );
92
113
  }
93
114
 
94
- // ============================================================================
95
- // COMPONENT: Right Side Chat Bubble Message Item
96
- // ============================================================================
97
-
98
- function SessionMessageBubble({ message }: { message: SessionMessageView }) {
99
- const isUser = message.role.toLowerCase() === 'user';
100
- const content = extractMessageText(message.content).trim();
115
+ function SessionMessageBubble({ message }: { message: NcpMessageView }) {
116
+ const role = normalizeNcpRole(message.role);
117
+ const isUser = role === 'user';
118
+ const content = extractNcpMessageText(message);
101
119
 
102
120
  return (
103
- <div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
104
- <div className={cn(
105
- "max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm",
106
- isUser
107
- ? "bg-primary text-white rounded-tr-sm"
108
- : "bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50"
109
- )}>
121
+ <div className={cn('flex w-full mb-6', isUser ? 'justify-end' : 'justify-start')}>
122
+ <div
123
+ className={cn(
124
+ 'max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm',
125
+ isUser
126
+ ? 'bg-primary text-white rounded-tr-sm'
127
+ : 'bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50'
128
+ )}
129
+ >
110
130
  <div className="shrink-0 pt-0.5">
111
131
  {isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
112
132
  </div>
113
133
  <div className="flex-1 space-y-1 overflow-x-hidden">
114
134
  <div className="flex items-baseline justify-between gap-4 mb-2">
115
- <span className={cn("font-semibold text-xs", isUser ? "text-primary-50" : "text-gray-900 capitalize")}>
116
- {message.role}
135
+ <span className={cn('font-semibold text-xs capitalize', isUser ? 'text-primary-50' : 'text-gray-900')}>
136
+ {role}
117
137
  </span>
118
- <span className={cn("text-[10px]", isUser ? "text-primary-200" : "text-gray-400")}>
138
+ <span className={cn('text-[10px]', isUser ? 'text-primary-200' : 'text-gray-400')}>
119
139
  {formatDate(message.timestamp)}
120
140
  </span>
121
141
  </div>
@@ -128,60 +148,63 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
128
148
  );
129
149
  }
130
150
 
131
- // ============================================================================
132
- // MAIN PAGE COMPONENT
133
- // ============================================================================
134
-
135
151
  export function SessionsConfig() {
136
152
  const [query, setQuery] = useState('');
137
153
  const [limit] = useState(100);
138
- const [activeMinutes] = useState(0);
139
- const [selectedKey, setSelectedKey] = useState<string | null>(null);
154
+ const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
140
155
  const [selectedChannel, setSelectedChannel] = useState<string>('all');
141
-
142
- // Local state drafts for editing the currently selected session
143
156
  const [draftLabel, setDraftLabel] = useState('');
144
157
  const [draftModel, setDraftModel] = useState('');
145
158
  const [isEditingMeta, setIsEditingMeta] = useState(false);
146
159
 
147
- const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
148
- const sessionsQuery = useSessions(sessionsParams);
149
- const activeRunsQuery = useChatRuns({ states: ['queued', 'running'], limit: 200 });
150
- const historyQuery = useSessionHistory(selectedKey, 200);
151
-
152
- const updateSession = useUpdateSession();
153
- const deleteSession = useDeleteSession();
160
+ const sessionsQuery = useNcpSessions({ limit });
161
+ const historyQuery = useNcpSessionMessages(selectedSessionId, 300);
162
+ const updateSession = useUpdateNcpSession();
163
+ const deleteSession = useDeleteNcpSession();
154
164
  const { confirm, ConfirmDialog } = useConfirmDialog();
155
165
 
156
- const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
157
- const activeRunBySessionKey = useMemo(
158
- () => buildActiveRunBySessionKey(activeRunsQuery.data?.runs ?? []),
159
- [activeRunsQuery.data?.runs]
166
+ const sessionSummaries = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
167
+ const sessionEntries = useMemo(() => adaptNcpSessionSummaries(sessionSummaries), [sessionSummaries]);
168
+ const sessionSummaryById = useMemo(() => new Map(sessionSummaries.map((session) => [session.sessionId, session])), [sessionSummaries]);
169
+ const filteredSessions = useMemo(() => {
170
+ const normalizedQuery = query.trim().toLowerCase();
171
+ return sessionEntries.filter((session) => {
172
+ if (selectedChannel !== 'all' && resolveChannelFromSessionKey(session.key) !== selectedChannel) {
173
+ return false;
174
+ }
175
+ if (!normalizedQuery) {
176
+ return true;
177
+ }
178
+ return session.key.toLowerCase().includes(normalizedQuery) || sessionDisplayName(session).toLowerCase().includes(normalizedQuery);
179
+ });
180
+ }, [query, selectedChannel, sessionEntries]);
181
+ const selectedSession = useMemo(
182
+ () => sessionEntries.find((session) => session.key === selectedSessionId) ?? null,
183
+ [selectedSessionId, sessionEntries]
160
184
  );
161
- const sessionRunStatusByKey = useMemo(
162
- () => buildSessionRunStatusByKey(activeRunBySessionKey),
163
- [activeRunBySessionKey]
185
+ const selectedSummary = useMemo(
186
+ () => (selectedSessionId ? sessionSummaryById.get(selectedSessionId) ?? null : null),
187
+ [selectedSessionId, sessionSummaryById]
164
188
  );
165
- const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
166
189
 
167
190
  const channels = useMemo(() => {
168
191
  const set = new Set<string>();
169
- for (const s of sessions) {
170
- set.add(resolveChannelFromSessionKey(s.key));
192
+ for (const session of sessionEntries) {
193
+ set.add(resolveChannelFromSessionKey(session.key));
171
194
  }
172
195
  return Array.from(set).sort((a, b) => {
173
196
  if (a === UNKNOWN_CHANNEL_KEY) return 1;
174
197
  if (b === UNKNOWN_CHANNEL_KEY) return -1;
175
198
  return a.localeCompare(b);
176
199
  });
177
- }, [sessions]);
200
+ }, [sessionEntries]);
178
201
 
179
- const filteredSessions = useMemo(() => {
180
- if (selectedChannel === 'all') return sessions;
181
- return sessions.filter(s => resolveChannelFromSessionKey(s.key) === selectedChannel);
182
- }, [sessions, selectedChannel]);
202
+ useEffect(() => {
203
+ if (selectedSessionId && !sessionSummaryById.has(selectedSessionId)) {
204
+ setSelectedSessionId(null);
205
+ }
206
+ }, [selectedSessionId, sessionSummaryById]);
183
207
 
184
- // Sync draft states when selecting a new session
185
208
  useEffect(() => {
186
209
  if (selectedSession) {
187
210
  setDraftLabel(selectedSession.label || '');
@@ -190,35 +213,23 @@ export function SessionsConfig() {
190
213
  setDraftLabel('');
191
214
  setDraftModel('');
192
215
  }
193
- setIsEditingMeta(false); // Reset editing state when switching sessions
216
+ setIsEditingMeta(false);
194
217
  }, [selectedSession]);
195
218
 
196
219
  const handleSaveMeta = () => {
197
- if (!selectedKey) return;
220
+ if (!selectedSessionId) return;
198
221
  updateSession.mutate({
199
- key: selectedKey,
222
+ sessionId: selectedSessionId,
200
223
  data: {
201
224
  label: draftLabel.trim() || null,
202
225
  preferredModel: draftModel.trim() || null
203
226
  }
204
227
  });
205
- setIsEditingMeta(false); // Close editor on save
206
- };
207
-
208
- const handleClearHistory = async () => {
209
- if (!selectedKey) return;
210
- const confirmed = await confirm({
211
- title: t('sessionsClearHistory') + '?',
212
- variant: 'destructive',
213
- confirmLabel: t('sessionsClearHistory')
214
- });
215
- if (confirmed) {
216
- updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
217
- }
228
+ setIsEditingMeta(false);
218
229
  };
219
230
 
220
231
  const handleDeleteSession = async () => {
221
- if (!selectedKey) return;
232
+ if (!selectedSessionId) return;
222
233
  const confirmed = await confirm({
223
234
  title: t('sessionsDeleteConfirm') + '?',
224
235
  variant: 'destructive',
@@ -226,9 +237,9 @@ export function SessionsConfig() {
226
237
  });
227
238
  if (confirmed) {
228
239
  deleteSession.mutate(
229
- { key: selectedKey },
240
+ { sessionId: selectedSessionId },
230
241
  {
231
- onSuccess: () => setSelectedKey(null)
242
+ onSuccess: () => setSelectedSessionId(null)
232
243
  }
233
244
  );
234
245
  }
@@ -238,20 +249,20 @@ export function SessionsConfig() {
238
249
  <PageLayout fullHeight>
239
250
  <PageHeader title={t('sessionsPageTitle')} description={t('sessionsPageDescription')} />
240
251
 
241
- {/* Main Mailbox Layout */}
242
252
  <div className="flex-1 flex gap-6 min-h-0 relative">
243
-
244
- {/* LEFT COLUMN: List Card */}
245
253
  <div className="w-[320px] flex flex-col shrink-0 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
246
-
247
- {/* List Card Header & Toolbar */}
248
254
  <div className="px-4 py-4 border-b border-gray-100 bg-white z-10 shrink-0 space-y-3">
249
255
  <div className="flex items-center justify-between">
250
256
  <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
251
- {sessions.length} {t('sessionsListTitle')}
257
+ {sessionEntries.length} {t('sessionsListTitle')}
252
258
  </span>
253
- <Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100" onClick={() => sessionsQuery.refetch()}>
254
- <RefreshCw className={cn("h-3.5 w-3.5", sessionsQuery.isFetching && "animate-spin")} />
259
+ <Button
260
+ variant="ghost"
261
+ size="icon"
262
+ className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
263
+ onClick={() => sessionsQuery.refetch()}
264
+ >
265
+ <RefreshCw className={cn('h-3.5 w-3.5', sessionsQuery.isFetching && 'animate-spin')} />
255
266
  </Button>
256
267
  </div>
257
268
 
@@ -261,8 +272,10 @@ export function SessionsConfig() {
261
272
  </SelectTrigger>
262
273
  <SelectContent className="rounded-xl shadow-lg border-gray-100 max-w-[280px]">
263
274
  <SelectItem value="all" className="rounded-lg text-xs">{t('sessionsAllChannels')}</SelectItem>
264
- {channels.map(c => (
265
- <SelectItem key={c} value={c} className="rounded-lg text-xs truncate pr-6">{displayChannelName(c)}</SelectItem>
275
+ {channels.map((channel) => (
276
+ <SelectItem key={channel} value={channel} className="rounded-lg text-xs truncate pr-6">
277
+ {displayChannelName(channel)}
278
+ </SelectItem>
266
279
  ))}
267
280
  </SelectContent>
268
281
  </Select>
@@ -271,7 +284,7 @@ export function SessionsConfig() {
271
284
  <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
272
285
  <Input
273
286
  value={query}
274
- onChange={(e) => setQuery(e.target.value)}
287
+ onChange={(event) => setQuery(event.target.value)}
275
288
  placeholder={t('sessionsSearchPlaceholder')}
276
289
  className="pl-8 h-8.5 rounded-lg bg-gray-50/50 border-gray-200 focus-visible:bg-white text-xs"
277
290
  />
@@ -287,32 +300,35 @@ export function SessionsConfig() {
287
300
  {t('sessionsEmpty')}
288
301
  </div>
289
302
  ) : (
290
- filteredSessions.map(session => (
291
- <SessionListItem
292
- key={session.key}
293
- session={session}
294
- channel={resolveChannelFromSessionKey(session.key)}
295
- runStatus={sessionRunStatusByKey.get(session.key)}
296
- isSelected={selectedKey === session.key}
297
- onSelect={() => setSelectedKey(session.key)}
298
- />
299
- ))
303
+ filteredSessions.map((session) => {
304
+ const summary = sessionSummaryById.get(session.key);
305
+ if (!summary) {
306
+ return null;
307
+ }
308
+ return (
309
+ <SessionListItem
310
+ key={session.key}
311
+ session={session}
312
+ summary={summary}
313
+ channel={resolveChannelFromSessionKey(session.key)}
314
+ isSelected={selectedSessionId === session.key}
315
+ onSelect={() => setSelectedSessionId(session.key)}
316
+ />
317
+ );
318
+ })
300
319
  )}
301
320
  </div>
302
321
  </div>
303
322
 
304
- {/* RIGHT COLUMN: Detail View Card */}
305
323
  <div className="flex-1 min-w-0 flex flex-col overflow-hidden relative bg-white rounded-2xl shadow-sm border border-gray-200">
306
-
307
324
  {(updateSession.isPending || deleteSession.isPending) && (
308
325
  <div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-20">
309
326
  <div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
310
327
  </div>
311
328
  )}
312
329
 
313
- {selectedKey && selectedSession ? (
330
+ {selectedSessionId && selectedSession && selectedSummary ? (
314
331
  <>
315
- {/* Detail Header / Metdata Editor */}
316
332
  <div className="shrink-0 border-b border-gray-100 bg-white px-8 py-5 z-10 space-y-4">
317
333
  <div className="flex items-center justify-between">
318
334
  <div className="flex items-center gap-4">
@@ -322,26 +338,34 @@ export function SessionsConfig() {
322
338
  <div>
323
339
  <div className="flex items-center gap-2.5 mb-1.5">
324
340
  <h3 className="text-lg font-bold text-gray-900 tracking-tight">
325
- {selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
341
+ {sessionDisplayName(selectedSession)}
326
342
  </h3>
327
343
  <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase tracking-widest">
328
344
  {displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
329
345
  </span>
346
+ {selectedSummary.status === 'running' ? <SessionRunBadge status="running" className="h-4 w-4" /> : null}
330
347
  </div>
331
- <div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedKey}>
332
- {selectedKey}
348
+ <div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedSessionId}>
349
+ {selectedSessionId}
333
350
  </div>
334
351
  </div>
335
352
  </div>
336
353
  <div className="flex items-center gap-2 shrink-0">
337
- <Button variant="outline" size="sm" onClick={() => setIsEditingMeta(!isEditingMeta)} className={cn("h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold", isEditingMeta ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50 hover:text-gray-900")}>
354
+ <Button
355
+ variant="outline"
356
+ size="sm"
357
+ onClick={() => setIsEditingMeta(!isEditingMeta)}
358
+ className={cn('h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold', isEditingMeta ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50 hover:text-gray-900')}
359
+ >
338
360
  <SettingsIcon className="w-3.5 h-3.5 mr-1.5" />
339
361
  {t('sessionsMetadata')}
340
362
  </Button>
341
- <Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8.5 rounded-lg shadow-none hover:bg-gray-50 hover:text-gray-900 border-gray-200 text-xs font-semibold text-gray-500">
342
- {t('sessionsClearHistory')}
343
- </Button>
344
- <Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500">
363
+ <Button
364
+ variant="outline"
365
+ size="sm"
366
+ onClick={handleDeleteSession}
367
+ className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500"
368
+ >
345
369
  {t('delete')}
346
370
  </Button>
347
371
  </div>
@@ -352,13 +376,13 @@ export function SessionsConfig() {
352
376
  <Input
353
377
  placeholder={t('sessionsLabelPlaceholder')}
354
378
  value={draftLabel}
355
- onChange={e => setDraftLabel(e.target.value)}
379
+ onChange={(event) => setDraftLabel(event.target.value)}
356
380
  className="h-8 text-sm bg-white"
357
381
  />
358
382
  <Input
359
383
  placeholder={t('sessionsModelPlaceholder')}
360
384
  value={draftModel}
361
- onChange={e => setDraftModel(e.target.value)}
385
+ onChange={(event) => setDraftModel(event.target.value)}
362
386
  className="h-8 text-sm bg-white"
363
387
  />
364
388
  <Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
@@ -368,10 +392,7 @@ export function SessionsConfig() {
368
392
  )}
369
393
  </div>
370
394
 
371
- {/* Chat History Area */}
372
- <div className="flex-1 overflow-y-auto p-6 relative
373
- [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
374
-
395
+ <div className="flex-1 overflow-y-auto p-6 relative [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
375
396
  {historyQuery.isLoading && (
376
397
  <div className="absolute inset-0 flex items-center justify-center bg-gray-50/50 backdrop-blur-sm z-10">
377
398
  <div className="flex flex-col items-center gap-3 animate-pulse">
@@ -395,14 +416,13 @@ export function SessionsConfig() {
395
416
  )}
396
417
 
397
418
  <div className="max-w-3xl mx-auto">
398
- {(historyQuery.data?.messages ?? []).map((message, idx) => (
399
- <SessionMessageBubble key={`${message.timestamp}-${idx}`} message={message} />
419
+ {(historyQuery.data?.messages ?? []).map((message) => (
420
+ <SessionMessageBubble key={message.id} message={message} />
400
421
  ))}
401
422
  </div>
402
423
  </div>
403
424
  </>
404
425
  ) : (
405
- /* Empty State */
406
426
  <div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
407
427
  <div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.02)] rotate-3">
408
428
  <Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
@@ -23,11 +23,11 @@ describe('auth status bootstrap retry policy', () => {
23
23
  });
24
24
 
25
25
  it('stops retrying after the bootstrap retry budget is exhausted', () => {
26
- expect(shouldRetryAuthStatusBootstrap(19, new Error('Failed to fetch'))).toBe(true);
27
- expect(shouldRetryAuthStatusBootstrap(20, new Error('Failed to fetch'))).toBe(false);
26
+ expect(shouldRetryAuthStatusBootstrap(39, new Error('Failed to fetch'))).toBe(true);
27
+ expect(shouldRetryAuthStatusBootstrap(40, new Error('Failed to fetch'))).toBe(false);
28
28
  });
29
29
 
30
30
  it('keeps the retry delay short and predictable', () => {
31
- expect(AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS).toBe(500);
31
+ expect(AUTH_STATUS_BOOTSTRAP_RETRY_DELAY_MS).toBe(250);
32
32
  });
33
33
  });