@nextclaw/ui 0.12.5 → 0.12.6

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 (113) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/ChannelsList-D8p4OlM6.js +8 -0
  3. package/dist/assets/ChatPage-A45t1Rmf.js +58 -0
  4. package/dist/assets/DocBrowser-B2MpsnU9.js +1 -0
  5. package/dist/assets/{DocBrowser-QUZ3nfmH.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CpiIfhJO.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-BUK13xK5.js → LogoBadge-BdxMPc9v.js} +1 -1
  8. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +1 -0
  9. package/dist/assets/MarketplacePage-BbpAkllU.js +49 -0
  10. package/dist/assets/{McpMarketplacePage-BG4T_Pcx.js → McpMarketplacePage-CxPFOgxv.js} +2 -2
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/{ProviderScopedModelInput-DGn6sFEN.js → ProviderScopedModelInput-BYNouw-i.js} +1 -1
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-ff15qO-c.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/{RuntimeConfig-TgPandXF.js → RuntimeConfig-ChdfK4Y_.js} +1 -1
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-Bew4EF2A.js → SecretsConfig-CCYO6NcV.js} +2 -2
  18. package/dist/assets/SessionsConfig-Du39vDgt.js +2 -0
  19. package/dist/assets/app-query-client-Dr5d-K8d.js +1 -0
  20. package/dist/assets/{book-open-CJG8Yz3U.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-D5b3Iyas.js → chunk-JZWAC4HX-CoFVxHXV.js} +1 -1
  23. package/dist/assets/client-CSk58DcF.js +7 -0
  24. package/dist/assets/config-D8KzikVB.js +1 -0
  25. package/dist/assets/{createLucideIcon-_FMJqZw2.js → createLucideIcon-83gaZMtv.js} +1 -1
  26. package/dist/assets/desktop-update-config-CfoVwf-w.js +1 -0
  27. package/dist/assets/dist-aTmhMDVh.js +9 -0
  28. package/dist/assets/{dist-B1fpOuON.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-b7gAJWYY.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-Bhy4TwfZ.js → hash-DaFBEkmi.js} +1 -1
  31. package/dist/assets/i18n-C3jb83S6.js +1 -0
  32. package/dist/assets/index-CE4N7ItL.css +1 -0
  33. package/dist/assets/index-riX7Sg0_.js +6 -0
  34. package/dist/assets/infiniteQueryBehavior-BmHX_ayZ.js +1 -0
  35. package/dist/assets/loader-circle-BjMg63eu.js +1 -0
  36. package/dist/assets/{logos-GMeYU9vc.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-C8UbWuMt.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-8HSx9wQj.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-CA4_C7Zg.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-BtvMy4lk.js → save-Us9fg4Sj.js} +1 -1
  43. package/dist/assets/search-B_Qr0f6C.js +1 -0
  44. package/dist/assets/security-config-BGWYwxNr.js +1 -0
  45. package/dist/assets/{select-xp_Ac8ip.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-Cn4Pp7DZ.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-BTi6UOij.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-BiiN8DME.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-BpsF0N-r.js → trash-2-Db-mZOZs.js} +1 -1
  51. package/dist/assets/use-infinite-scroll-loader-DBJX5hj0.js +1 -0
  52. package/dist/assets/{useConfirmDialog-BJIwUZjH.js → useConfirmDialog-DL0a-oGC.js} +1 -1
  53. package/dist/assets/useMutation-BdZm-9PL.js +1 -0
  54. package/dist/assets/x-B8Tho_xC.js +1 -0
  55. package/dist/index.html +20 -19
  56. package/package.json +5 -5
  57. package/src/App.tsx +2 -0
  58. package/src/api/raw-client.test.ts +37 -0
  59. package/src/api/raw-client.ts +51 -8
  60. package/src/components/chat/ChatConversationPanel.test.tsx +161 -1
  61. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  62. package/src/components/chat/ChatSidebar.tsx +62 -9
  63. package/src/components/chat/chat-child-session-panel.tsx +56 -18
  64. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  65. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  66. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  67. package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -0
  68. package/src/components/chat/managers/chat-session-list.manager.ts +13 -0
  69. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  70. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  71. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +18 -5
  72. package/src/components/chat/stores/chat-session-list.store.ts +96 -5
  73. package/src/components/config/ProviderForm.tsx +9 -15
  74. package/src/components/config/desktop-update-config.tsx +230 -0
  75. package/src/components/layout/Sidebar.tsx +6 -1
  76. package/src/components/layout/sidebar.layout.test.tsx +1 -0
  77. package/src/desktop/desktop-update.types.ts +36 -0
  78. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  79. package/src/desktop/stores/desktop-update.store.ts +18 -0
  80. package/src/lib/desktop-update-labels.utils.ts +72 -0
  81. package/src/lib/i18n.chat.ts +13 -0
  82. package/src/lib/i18n.ts +3 -9
  83. package/src/lib/ui-document-title.ts +1 -0
  84. package/src/transport/local.transport.ts +57 -18
  85. package/src/vite-env.d.ts +10 -0
  86. package/dist/assets/ChannelsList-C6-lh55g.js +0 -8
  87. package/dist/assets/ChatPage-DOW0gPc2.js +0 -45
  88. package/dist/assets/DocBrowser-CGyeswYP.js +0 -1
  89. package/dist/assets/MarketplacePage-BDVwhIYE.js +0 -1
  90. package/dist/assets/MarketplacePage-LnKKL3xK.js +0 -49
  91. package/dist/assets/ModelConfig-LtWuogIw.js +0 -1
  92. package/dist/assets/ProvidersList-ma-_MlLo.js +0 -1
  93. package/dist/assets/SearchConfig-C9iBt7pl.js +0 -1
  94. package/dist/assets/SessionsConfig-2r2yAGZg.js +0 -2
  95. package/dist/assets/chat-session-display-DkAC5OMC.js +0 -1
  96. package/dist/assets/config-zvnxSXSP.js +0 -1
  97. package/dist/assets/dist-BCXX7FD-.js +0 -15
  98. package/dist/assets/i18n-DJg9BPYk.js +0 -1
  99. package/dist/assets/index-BoJbxdvZ.css +0 -1
  100. package/dist/assets/index-CtlT4E9Y.js +0 -6
  101. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +0 -1
  102. package/dist/assets/loader-circle-B60I0hEk.js +0 -1
  103. package/dist/assets/plus-CR7RfK3H.js +0 -1
  104. package/dist/assets/react-BB4jko2M.js +0 -1
  105. package/dist/assets/search-C60UA27E.js +0 -1
  106. package/dist/assets/security-config-BkFDYZ6j.js +0 -1
  107. package/dist/assets/skeleton-uxz_5h3A.js +0 -1
  108. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +0 -1
  109. package/dist/assets/useMutation-BjBOKHj_.js +0 -1
  110. package/dist/assets/x-BfTu-g7D.js +0 -1
  111. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  112. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  113. /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
@@ -13,6 +13,7 @@ import { Check, Pencil, X } from 'lucide-react';
13
13
  type ChatSidebarSessionItemProps = {
14
14
  session: SessionEntryView;
15
15
  active: boolean;
16
+ showUnreadDot: boolean;
16
17
  runStatus?: SessionRunStatus;
17
18
  context: SessionContextView;
18
19
  title: string;
@@ -29,32 +30,180 @@ type ChatSidebarSessionItemProps = {
29
30
  onCancel: () => void;
30
31
  };
31
32
 
32
- export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
33
- const {
34
- session,
35
- active,
36
- runStatus,
37
- context,
38
- title,
39
- agentId,
40
- agentLabel,
41
- agentAvatarUrl,
42
- isEditing,
43
- draftLabel,
44
- isSaving,
45
- onSelect,
46
- onStartEditing,
47
- onDraftLabelChange,
48
- onSave,
49
- onCancel
50
- } = props;
33
+ type ChatSidebarSessionEditingViewProps = Pick<
34
+ ChatSidebarSessionItemProps,
35
+ 'session' | 'draftLabel' | 'isSaving' | 'onDraftLabelChange' | 'onSave' | 'onCancel'
36
+ >;
51
37
 
38
+ function ChatSidebarSessionEditingView({
39
+ session,
40
+ draftLabel,
41
+ isSaving,
42
+ onDraftLabelChange,
43
+ onSave,
44
+ onCancel
45
+ }: ChatSidebarSessionEditingViewProps) {
46
+ return (
47
+ <div className="space-y-2">
48
+ <Input
49
+ value={draftLabel}
50
+ onChange={(event) => onDraftLabelChange(event.target.value)}
51
+ onKeyDown={(event) => {
52
+ if (event.key === 'Enter') {
53
+ event.preventDefault();
54
+ void onSave();
55
+ } else if (event.key === 'Escape') {
56
+ event.preventDefault();
57
+ onCancel();
58
+ }
59
+ }}
60
+ placeholder={t('sessionsLabelPlaceholder')}
61
+ className="h-8 rounded-lg border-gray-300 bg-white text-xs"
62
+ autoFocus
63
+ disabled={isSaving}
64
+ />
65
+ <div className="flex items-center justify-between gap-2">
66
+ <div className="min-w-0 text-[11px] text-gray-400 truncate">{session.key}</div>
67
+ <div className="flex items-center gap-1">
68
+ <Button
69
+ type="button"
70
+ size="icon"
71
+ variant="ghost"
72
+ className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
73
+ onClick={() => void onSave()}
74
+ disabled={isSaving}
75
+ aria-label={t('save')}
76
+ >
77
+ <Check className="h-3.5 w-3.5" />
78
+ </Button>
79
+ <Button
80
+ type="button"
81
+ size="icon"
82
+ variant="ghost"
83
+ className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
84
+ onClick={onCancel}
85
+ disabled={isSaving}
86
+ aria-label={t('cancel')}
87
+ >
88
+ <X className="h-3.5 w-3.5" />
89
+ </Button>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ type ChatSidebarSessionDisplayViewProps = Omit<
97
+ ChatSidebarSessionItemProps,
98
+ 'isEditing' | 'draftLabel' | 'isSaving' | 'onDraftLabelChange' | 'onSave' | 'onCancel'
99
+ >;
100
+
101
+ function ChatSidebarSessionDisplayView({
102
+ session,
103
+ active,
104
+ showUnreadDot,
105
+ runStatus,
106
+ context,
107
+ title,
108
+ agentId,
109
+ agentLabel,
110
+ agentAvatarUrl,
111
+ onSelect,
112
+ onStartEditing
113
+ }: ChatSidebarSessionDisplayViewProps) {
52
114
  const iconTone = active ? 'text-gray-700' : 'text-gray-500';
53
115
  const normalizedAgentId = agentId?.trim() ?? '';
54
116
  const shouldShowAgentAvatar = Boolean(
55
117
  normalizedAgentId && normalizedAgentId.toLowerCase() !== 'main',
56
118
  );
57
119
 
120
+ return (
121
+ <div className="group/session relative">
122
+ <button type="button" onClick={onSelect} className="w-full text-left">
123
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 pr-8">
124
+ <span className="flex min-w-0 items-center gap-1.5">
125
+ {shouldShowAgentAvatar ? (
126
+ <AgentAvatar
127
+ agentId={normalizedAgentId}
128
+ displayName={agentLabel}
129
+ avatarUrl={agentAvatarUrl}
130
+ className="h-5 w-5 shrink-0"
131
+ />
132
+ ) : null}
133
+ <span className="truncate font-medium">{title}</span>
134
+ {context.label ? (
135
+ <span
136
+ className={cn(
137
+ 'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
138
+ active
139
+ ? 'border-gray-300 bg-white/80 text-gray-700'
140
+ : 'border-gray-200 bg-gray-100 text-gray-500'
141
+ )}
142
+ >
143
+ {context.label}
144
+ </span>
145
+ ) : null}
146
+ {context.icon ? (
147
+ <span className="inline-flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center">
148
+ <SessionContextIconNode icon={context.icon} className={iconTone} />
149
+ </span>
150
+ ) : null}
151
+ </span>
152
+ <span className="inline-flex shrink-0 items-center justify-end gap-1.5">
153
+ {showUnreadDot ? (
154
+ <span
155
+ aria-label={t('chatSessionUnread')}
156
+ className="h-2 w-2 rounded-full bg-primary"
157
+ />
158
+ ) : null}
159
+ <span className="inline-flex h-3.5 w-3.5 items-center justify-center">
160
+ {runStatus ? <SessionRunBadge status={runStatus} /> : null}
161
+ </span>
162
+ </span>
163
+ </div>
164
+ <div className="mt-0.5 text-[11px] text-gray-400 truncate">
165
+ {agentLabel?.trim() ? `${agentLabel} · ` : ''}{session.messageCount} · {formatDateTime(session.updatedAt)}
166
+ </div>
167
+ </button>
168
+ <button
169
+ type="button"
170
+ onClick={(event) => {
171
+ event.stopPropagation();
172
+ onStartEditing();
173
+ }}
174
+ className={cn(
175
+ 'absolute right-0 top-0 inline-flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition-all hover:bg-white hover:text-gray-900',
176
+ active
177
+ ? 'opacity-100'
178
+ : 'opacity-0 group-hover/session:opacity-100 group-focus-within/session:opacity-100'
179
+ )}
180
+ aria-label={t('edit')}
181
+ >
182
+ <Pencil className="h-3.5 w-3.5" />
183
+ </button>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ export function ChatSidebarSessionItem({
189
+ session,
190
+ active,
191
+ showUnreadDot,
192
+ runStatus,
193
+ context,
194
+ title,
195
+ agentId,
196
+ agentLabel,
197
+ agentAvatarUrl,
198
+ isEditing,
199
+ draftLabel,
200
+ isSaving,
201
+ onSelect,
202
+ onStartEditing,
203
+ onDraftLabelChange,
204
+ onSave,
205
+ onCancel
206
+ }: ChatSidebarSessionItemProps) {
58
207
  return (
59
208
  <div
60
209
  className={cn(
@@ -65,109 +214,28 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
65
214
  )}
66
215
  >
67
216
  {isEditing ? (
68
- <div className="space-y-2">
69
- <Input
70
- value={draftLabel}
71
- onChange={(event) => onDraftLabelChange(event.target.value)}
72
- onKeyDown={(event) => {
73
- if (event.key === 'Enter') {
74
- event.preventDefault();
75
- void onSave();
76
- } else if (event.key === 'Escape') {
77
- event.preventDefault();
78
- onCancel();
79
- }
80
- }}
81
- placeholder={t('sessionsLabelPlaceholder')}
82
- className="h-8 rounded-lg border-gray-300 bg-white text-xs"
83
- autoFocus
84
- disabled={isSaving}
85
- />
86
- <div className="flex items-center justify-between gap-2">
87
- <div className="min-w-0 text-[11px] text-gray-400 truncate">{session.key}</div>
88
- <div className="flex items-center gap-1">
89
- <Button
90
- type="button"
91
- size="icon"
92
- variant="ghost"
93
- className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
94
- onClick={() => void onSave()}
95
- disabled={isSaving}
96
- aria-label={t('save')}
97
- >
98
- <Check className="h-3.5 w-3.5" />
99
- </Button>
100
- <Button
101
- type="button"
102
- size="icon"
103
- variant="ghost"
104
- className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
105
- onClick={onCancel}
106
- disabled={isSaving}
107
- aria-label={t('cancel')}
108
- >
109
- <X className="h-3.5 w-3.5" />
110
- </Button>
111
- </div>
112
- </div>
113
- </div>
217
+ <ChatSidebarSessionEditingView
218
+ session={session}
219
+ draftLabel={draftLabel}
220
+ isSaving={isSaving}
221
+ onDraftLabelChange={onDraftLabelChange}
222
+ onSave={onSave}
223
+ onCancel={onCancel}
224
+ />
114
225
  ) : (
115
- <div className="group/session relative">
116
- <button type="button" onClick={onSelect} className="w-full text-left">
117
- <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5 pr-8">
118
- <span className="flex min-w-0 items-center gap-1.5">
119
- {shouldShowAgentAvatar ? (
120
- <AgentAvatar
121
- agentId={normalizedAgentId}
122
- displayName={agentLabel}
123
- avatarUrl={agentAvatarUrl}
124
- className="h-5 w-5 shrink-0"
125
- />
126
- ) : null}
127
- <span className="truncate font-medium">{title}</span>
128
- {context.label ? (
129
- <span
130
- className={cn(
131
- 'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
132
- active
133
- ? 'border-gray-300 bg-white/80 text-gray-700'
134
- : 'border-gray-200 bg-gray-100 text-gray-500'
135
- )}
136
- >
137
- {context.label}
138
- </span>
139
- ) : null}
140
- {context.icon ? (
141
- <span className="inline-flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center">
142
- <SessionContextIconNode icon={context.icon} className={iconTone} />
143
- </span>
144
- ) : null}
145
- </span>
146
- <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
147
- {runStatus ? <SessionRunBadge status={runStatus} /> : null}
148
- </span>
149
- </div>
150
- <div className="mt-0.5 text-[11px] text-gray-400 truncate">
151
- {agentLabel?.trim() ? `${agentLabel} · ` : ''}{session.messageCount} · {formatDateTime(session.updatedAt)}
152
- </div>
153
- </button>
154
- <button
155
- type="button"
156
- onClick={(event) => {
157
- event.stopPropagation();
158
- onStartEditing();
159
- }}
160
- className={cn(
161
- 'absolute right-0 top-0 inline-flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition-all hover:bg-white hover:text-gray-900',
162
- active
163
- ? 'opacity-100'
164
- : 'opacity-0 group-hover/session:opacity-100 group-focus-within/session:opacity-100'
165
- )}
166
- aria-label={t('edit')}
167
- >
168
- <Pencil className="h-3.5 w-3.5" />
169
- </button>
170
- </div>
226
+ <ChatSidebarSessionDisplayView
227
+ session={session}
228
+ active={active}
229
+ showUnreadDot={showUnreadDot}
230
+ runStatus={runStatus}
231
+ context={context}
232
+ title={title}
233
+ agentId={agentId}
234
+ agentLabel={agentLabel}
235
+ agentAvatarUrl={agentAvatarUrl}
236
+ onSelect={onSelect}
237
+ onStartEditing={onStartEditing}
238
+ />
171
239
  )}
172
240
  </div>
173
241
  );
@@ -4,18 +4,19 @@ import { beforeEach, expect, it, vi } from "vitest";
4
4
  import { ChatMessageListContainer } from "./chat-message-list.container";
5
5
 
6
6
  const captures = vi.hoisted(() => ({
7
- renders: [] as Array<{ messages: unknown[] }>,
7
+ renders: [] as Array<{ messages: unknown[]; texts?: Record<string, unknown> }>,
8
+ language: "en",
8
9
  }));
9
10
 
10
11
  vi.mock("@nextclaw/agent-chat-ui", () => ({
11
- ChatMessageList: (props: { messages: unknown[] }) => {
12
+ ChatMessageList: (props: { messages: unknown[]; texts?: Record<string, unknown> }) => {
12
13
  captures.renders.push(props);
13
14
  return <div data-testid="chat-message-list" />;
14
15
  },
15
16
  }));
16
17
 
17
18
  vi.mock("@/components/providers/I18nProvider", () => ({
18
- useI18n: () => ({ language: "en" }),
19
+ useI18n: () => ({ language: captures.language }),
19
20
  }));
20
21
 
21
22
  vi.mock("@/lib/i18n", () => ({
@@ -25,6 +26,7 @@ vi.mock("@/lib/i18n", () => ({
25
26
 
26
27
  beforeEach(() => {
27
28
  captures.renders = [];
29
+ captures.language = "en";
28
30
  });
29
31
 
30
32
  it("reuses adapted message references when the source message object is unchanged", () => {
@@ -144,3 +146,19 @@ it("adapts persisted inline token metadata into rich message parts", () => {
144
146
  ],
145
147
  });
146
148
  });
149
+
150
+ it("passes localized attachment card texts to the shared chat UI", () => {
151
+ captures.language = "zh";
152
+
153
+ render(<ChatMessageListContainer messages={[]} isSending={false} />);
154
+
155
+ expect(captures.renders[captures.renders.length - 1]?.texts).toMatchObject({
156
+ attachmentOpenLabel: "chatAttachmentOpen",
157
+ attachmentAttachedLabel: "chatAttachmentAttached",
158
+ attachmentCategoryLabels: {
159
+ archive: "chatAttachmentCategoryArchive",
160
+ pdf: "chatAttachmentCategoryPdf",
161
+ generic: "chatAttachmentCategoryGeneric",
162
+ },
163
+ });
164
+ });
@@ -65,6 +65,20 @@ function buildChatMessageTexts(language: string) {
65
65
  copyMessageLabel: t("chatMessageCopy"),
66
66
  copiedMessageLabel: t("chatMessageCopied"),
67
67
  typingLabel: t("chatTyping"),
68
+ attachmentOpenLabel: t("chatAttachmentOpen"),
69
+ attachmentAttachedLabel: t("chatAttachmentAttached"),
70
+ attachmentCategoryLabels: {
71
+ archive: t("chatAttachmentCategoryArchive"),
72
+ audio: t("chatAttachmentCategoryAudio"),
73
+ code: t("chatAttachmentCategoryCode"),
74
+ data: t("chatAttachmentCategoryData"),
75
+ document: t("chatAttachmentCategoryDocument"),
76
+ generic: t("chatAttachmentCategoryGeneric"),
77
+ image: t("chatAttachmentCategoryImage"),
78
+ pdf: t("chatAttachmentCategoryPdf"),
79
+ sheet: t("chatAttachmentCategorySheet"),
80
+ video: t("chatAttachmentCategoryVideo"),
81
+ },
68
82
  };
69
83
  }
70
84
 
@@ -15,6 +15,8 @@ describe('ChatSessionListManager', () => {
15
15
  }
16
16
  });
17
17
  useChatSessionListStore.setState({
18
+ readUpdatedAtBySessionKey: {},
19
+ hasHydratedReadWatermarks: false,
18
20
  snapshot: {
19
21
  ...useChatSessionListStore.getState().snapshot,
20
22
  selectedSessionKey: 'session-1',
@@ -122,4 +124,36 @@ describe('ChatSessionListManager', () => {
122
124
  expect(useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
123
125
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
124
126
  });
127
+
128
+ it('marks a session as read through the session list owner boundary', () => {
129
+ const manager = new ChatSessionListManager(
130
+ {} as ConstructorParameters<typeof ChatSessionListManager>[0],
131
+ {} as ConstructorParameters<typeof ChatSessionListManager>[1]
132
+ );
133
+
134
+ manager.markSessionRead('session-2', '2026-04-10T10:00:00.000Z');
135
+
136
+ expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
137
+ '2026-04-10T10:00:00.000Z'
138
+ );
139
+ });
140
+
141
+ it('hydrates the initial unread baseline through the session list owner boundary', () => {
142
+ const manager = new ChatSessionListManager(
143
+ {} as ConstructorParameters<typeof ChatSessionListManager>[0],
144
+ {} as ConstructorParameters<typeof ChatSessionListManager>[1]
145
+ );
146
+
147
+ manager.hydrateReadWatermarks([
148
+ {
149
+ sessionKey: 'session-2',
150
+ updatedAt: '2026-04-10T10:00:00.000Z'
151
+ }
152
+ ]);
153
+
154
+ expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
155
+ '2026-04-10T10:00:00.000Z'
156
+ );
157
+ expect(useChatSessionListStore.getState().hasHydratedReadWatermarks).toBe(true);
158
+ });
125
159
  });
@@ -46,6 +46,19 @@ export class ChatSessionListManager {
46
46
  useChatSessionListStore.getState().setSnapshot({ listMode: value });
47
47
  };
48
48
 
49
+ markSessionRead = (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => {
50
+ if (!sessionKey) {
51
+ return;
52
+ }
53
+ useChatSessionListStore.getState().markSessionRead(sessionKey, updatedAt);
54
+ };
55
+
56
+ hydrateReadWatermarks = (
57
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
58
+ ) => {
59
+ useChatSessionListStore.getState().hydrateReadWatermarks(entries);
60
+ };
61
+
49
62
  createSession = (sessionType?: string, projectRoot?: string | null): string => {
50
63
  const { snapshot } = useChatInputStore.getState();
51
64
  const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
@@ -55,7 +55,7 @@ describe('ncp-app-client-fetch', () => {
55
55
  'content-type': 'application/json'
56
56
  },
57
57
  body: JSON.stringify({ sessionId: 's1' })
58
- })).rejects.toThrow('Failed to fetch');
58
+ })).rejects.toThrow('NCP fetch failed for POST http://127.0.0.1:55667/api/ncp/agent/abort: Error: Failed to fetch');
59
59
  });
60
60
 
61
61
  it('preserves native SSE request headers', async () => {
@@ -1,9 +1,49 @@
1
1
  type FetchLike = typeof fetch;
2
2
 
3
+ function formatFetchTarget(input: RequestInfo | URL): string {
4
+ if (typeof input === 'string') {
5
+ return input;
6
+ }
7
+ if (input instanceof URL) {
8
+ return input.toString();
9
+ }
10
+ return input.url;
11
+ }
12
+
13
+ function formatUnknownFetchError(error: unknown): string {
14
+ if (error instanceof Error) {
15
+ const name = error.name?.trim();
16
+ const message = error.message?.trim();
17
+ if (name && message) {
18
+ return `${name}: ${message}`;
19
+ }
20
+ return message || name || 'Unknown Error';
21
+ }
22
+ return String(error ?? 'Unknown error');
23
+ }
24
+
25
+ function createErrorWithCause(message: string, cause: unknown): Error {
26
+ const error = new Error(message) as Error & { cause?: unknown };
27
+ if (cause !== undefined) {
28
+ error.cause = cause;
29
+ }
30
+ return error;
31
+ }
32
+
3
33
  export function createNcpAppClientFetch(): FetchLike {
4
- return (input, init) =>
5
- fetch(input, {
6
- credentials: 'include',
7
- ...init
8
- });
34
+ return async (input, init) => {
35
+ try {
36
+ return await fetch(input, {
37
+ credentials: 'include',
38
+ ...init
39
+ });
40
+ } catch (error) {
41
+ const method = (init?.method || 'GET').toUpperCase();
42
+ const target = formatFetchTarget(input);
43
+ throw createErrorWithCause(
44
+ `NCP fetch failed for ${method} ${target}: ${formatUnknownFetchError(error)}`,
45
+ error
46
+ );
47
+ }
48
+ };
9
49
  }
@@ -5,12 +5,15 @@ import { adaptNcpSessionSummaries } from "@/components/chat/ncp/ncp-session-adap
5
5
  import { resolveSessionTypeLabel } from "@/components/chat/useChatSessionTypeState";
6
6
  import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
7
7
  import { useNcpSessions } from "@/hooks/useConfig";
8
+ import type { SessionRunStatus } from "@/lib/session-run-status";
8
9
 
9
10
  export type ResolvedChildSessionTab = {
10
11
  sessionKey: string;
11
12
  parentSessionKey: string | null;
12
13
  title: string;
13
14
  agentId: string | null;
15
+ updatedAt: string | null;
16
+ runStatus?: SessionRunStatus;
14
17
  sessionTypeLabel: string | null;
15
18
  preferredModel: string | null;
16
19
  projectName: string | null;
@@ -34,24 +37,34 @@ export function useNcpChildSessionTabsView(
34
37
  tabs: readonly ChatChildSessionTab[],
35
38
  ): ResolvedChildSessionTab[] {
36
39
  const sessionsQuery = useNcpSessions({ limit: 200 });
40
+ const summaries = useMemo(
41
+ () => sessionsQuery.data?.sessions ?? [],
42
+ [sessionsQuery.data?.sessions],
43
+ );
37
44
 
38
45
  const sessionByKey = useMemo(() => {
39
- const sessions = adaptNcpSessionSummaries(
40
- sessionsQuery.data?.sessions ?? [],
41
- );
46
+ const sessions = adaptNcpSessionSummaries(summaries);
42
47
  return new Map(sessions.map((session) => [session.key, session]));
43
- }, [sessionsQuery.data?.sessions]);
48
+ }, [summaries]);
49
+
50
+ const summaryByKey = useMemo(
51
+ () => new Map(summaries.map((summary) => [summary.sessionId, summary])),
52
+ [summaries],
53
+ );
44
54
 
45
55
  return useMemo(
46
56
  () =>
47
57
  tabs.map((tab) => {
48
58
  const session = sessionByKey.get(tab.sessionKey) ?? null;
59
+ const summary = summaryByKey.get(tab.sessionKey) ?? null;
49
60
  const agentId = tab.agentId?.trim() || session?.agentId || null;
50
61
  return {
51
62
  sessionKey: tab.sessionKey,
52
63
  parentSessionKey: tab.parentSessionKey,
53
64
  title: resolveChildSessionTitle(tab, session),
54
65
  agentId,
66
+ updatedAt: session?.updatedAt ?? null,
67
+ runStatus: summary?.status === "running" ? "running" : undefined,
55
68
  sessionTypeLabel: session?.sessionType
56
69
  ? resolveSessionTypeLabel(session.sessionType)
57
70
  : null,
@@ -60,6 +73,6 @@ export function useNcpChildSessionTabsView(
60
73
  projectRoot: session?.projectRoot?.trim() || null,
61
74
  };
62
75
  }),
63
- [sessionByKey, tabs],
76
+ [sessionByKey, summaryByKey, tabs],
64
77
  );
65
78
  }