@nextclaw/ui 0.12.4 → 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 (149) hide show
  1. package/CHANGELOG.md +66 -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-NSzgVKka.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DpgVdRgk.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-CHS4YNLw.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-CxPFOgxv.js +40 -0
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-BYNouw-i.js +1 -0
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-yfbrveNQ.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +1 -0
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-CLFSSoTl.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-C7TAghTk.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-DbL4EmiT.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-BRLFtf-8.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-DP-JKR4G.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-BkJkiWbH.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-CbP6-6R9.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-N3dbS6-I.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-DyuvlNrg.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-BKKWGUaG.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-BGMdiNGq.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-Dh4GQzzX.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-BtIi5fnh.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-C4O-2jZP.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-DPegGIa_.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-x5GZexrF.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-CU3LYIpQ.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-S5WsGOGf.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 -18
  56. package/package.json +6 -6
  57. package/src/App.tsx +2 -0
  58. package/src/account/components/account-panel.tsx +46 -4
  59. package/src/account/managers/account.manager.ts +19 -4
  60. package/src/api/raw-client.test.ts +37 -0
  61. package/src/api/raw-client.ts +51 -8
  62. package/src/api/remote.ts +9 -0
  63. package/src/api/remote.types.ts +5 -0
  64. package/src/components/chat/ChatConversationPanel.test.tsx +344 -142
  65. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  66. package/src/components/chat/ChatSidebar.tsx +62 -9
  67. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  68. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  69. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  70. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  71. package/src/components/chat/chat-child-session-panel.tsx +155 -59
  72. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  73. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  74. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  75. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  76. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  77. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  78. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  79. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  80. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  81. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  82. package/src/components/chat/managers/chat-session-list.manager.test.ts +79 -5
  83. package/src/components/chat/managers/chat-session-list.manager.ts +31 -4
  84. package/src/components/chat/ncp/NcpChatPage.tsx +32 -51
  85. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  86. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  87. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  88. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  89. package/src/components/chat/ncp/ncp-chat.presenter.ts +1 -11
  90. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +35 -9
  91. package/src/components/chat/stores/chat-session-list.store.ts +99 -5
  92. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  93. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  94. package/src/components/config/ChannelsList.test.tsx +68 -0
  95. package/src/components/config/ChannelsList.tsx +22 -4
  96. package/src/components/config/ProviderForm.tsx +9 -15
  97. package/src/components/config/ProvidersList.tsx +17 -3
  98. package/src/components/config/desktop-update-config.tsx +230 -0
  99. package/src/components/config/providers-list.test.tsx +68 -0
  100. package/src/components/layout/Sidebar.tsx +19 -14
  101. package/src/components/layout/sidebar.layout.test.tsx +33 -1
  102. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  103. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  104. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  105. package/src/desktop/desktop-update.types.ts +36 -0
  106. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  107. package/src/desktop/stores/desktop-update.store.ts +18 -0
  108. package/src/hooks/marketplace-list-pages.ts +27 -0
  109. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  110. package/src/hooks/useMarketplace.ts +14 -3
  111. package/src/hooks/useMcpMarketplace.ts +14 -3
  112. package/src/lib/desktop-update-labels.utils.ts +72 -0
  113. package/src/lib/i18n.chat.ts +13 -0
  114. package/src/lib/i18n.remote.ts +15 -0
  115. package/src/lib/i18n.ts +3 -9
  116. package/src/lib/ui-document-title.ts +1 -0
  117. package/src/transport/local.transport.ts +57 -18
  118. package/src/vite-env.d.ts +10 -0
  119. package/dist/assets/ChannelsList-CobWeI2V.js +0 -8
  120. package/dist/assets/ChatPage-ZIdFFVAv.js +0 -43
  121. package/dist/assets/DocBrowser-D55C0iyl.js +0 -1
  122. package/dist/assets/MarketplacePage-BFYsRss_.js +0 -49
  123. package/dist/assets/MarketplacePage-DII-q-Y1.js +0 -1
  124. package/dist/assets/McpMarketplacePage-CPqsGJzz.js +0 -40
  125. package/dist/assets/ModelConfig-Bvuo_IpS.js +0 -1
  126. package/dist/assets/ProviderScopedModelInput-BfY8rGsf.js +0 -1
  127. package/dist/assets/ProvidersList-3tlaqwSS.js +0 -1
  128. package/dist/assets/RuntimeConfig-CAd5Kta3.js +0 -1
  129. package/dist/assets/SearchConfig-DFwgaAa7.js +0 -1
  130. package/dist/assets/SessionsConfig-vYrvc2Fk.js +0 -2
  131. package/dist/assets/chat-session-display-5dVFkJyw.js +0 -1
  132. package/dist/assets/config-CMiW0yaK.js +0 -1
  133. package/dist/assets/dist-BFc_H-lY.js +0 -15
  134. package/dist/assets/i18n-C_2dKw6w.js +0 -1
  135. package/dist/assets/index-ChUXhq0G.css +0 -1
  136. package/dist/assets/index-DAE8Srx-.js +0 -6
  137. package/dist/assets/label-D8yyejJS.js +0 -1
  138. package/dist/assets/loader-circle-B0sKKO29.js +0 -1
  139. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  140. package/dist/assets/plus-CYXs3JtZ.js +0 -1
  141. package/dist/assets/react-8EIEQjMP.js +0 -1
  142. package/dist/assets/search-DOsLw-P9.js +0 -1
  143. package/dist/assets/security-config-CM_tQRXQ.js +0 -1
  144. package/dist/assets/skeleton-GbHLjPC0.js +0 -1
  145. package/dist/assets/useMutation-DSinpgEq.js +0 -1
  146. package/dist/assets/x-Bnco_K8b.js +0 -1
  147. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  148. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  149. /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
 
@@ -71,7 +71,7 @@ describe('useChatSessionProject', () => {
71
71
  expect(toast.success).toHaveBeenCalledTimes(1);
72
72
  });
73
73
 
74
- it('persists to the server and mirrors the updated project override locally for an existing session', async () => {
74
+ it('persists to the server without reusing the draft override state for an existing session', async () => {
75
75
  const { result } = renderHook(() => useChatSessionProject());
76
76
 
77
77
  await act(async () => {
@@ -88,12 +88,12 @@ describe('useChatSessionProject', () => {
88
88
  successMessage: 'Project directory updated',
89
89
  });
90
90
  expect(useChatInputStore.getState().snapshot).toMatchObject({
91
- pendingProjectRoot: '/tmp/project-beta',
92
- pendingProjectRootSessionKey: 'session-1',
91
+ pendingProjectRoot: null,
92
+ pendingProjectRootSessionKey: null,
93
93
  });
94
94
  });
95
95
 
96
- it('persists clearing to the server and keeps the cleared override until session state catches up', async () => {
96
+ it('persists clearing to the server without keeping a session-scoped local override', async () => {
97
97
  const { result } = renderHook(() => useChatSessionProject());
98
98
 
99
99
  await act(async () => {
@@ -111,7 +111,7 @@ describe('useChatSessionProject', () => {
111
111
  });
112
112
  expect(useChatInputStore.getState().snapshot).toMatchObject({
113
113
  pendingProjectRoot: null,
114
- pendingProjectRootSessionKey: 'session-1',
114
+ pendingProjectRootSessionKey: null,
115
115
  });
116
116
  });
117
117
  });
@@ -31,10 +31,5 @@ export function useChatSessionProject() {
31
31
  patch: { projectRoot: params.projectRoot },
32
32
  successMessage,
33
33
  });
34
-
35
- useChatInputStore.getState().setSnapshot({
36
- pendingProjectRoot: params.projectRoot,
37
- pendingProjectRootSessionKey: params.sessionKey,
38
- });
39
34
  };
40
35
  }
@@ -0,0 +1,75 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import type { ReactNode } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { toast } from 'sonner';
6
+ import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ updateNcpSession: vi.fn(),
10
+ upsertNcpSessionSummaryInQueryClient: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('sonner', () => ({
14
+ toast: {
15
+ success: vi.fn(),
16
+ error: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ vi.mock('@/api/ncp-session', () => ({
21
+ updateNcpSession: (...args: unknown[]) => mocks.updateNcpSession(...args),
22
+ }));
23
+
24
+ vi.mock('@/api/ncp-session-query-cache', () => ({
25
+ upsertNcpSessionSummaryInQueryClient: (...args: unknown[]) =>
26
+ mocks.upsertNcpSessionSummaryInQueryClient(...args),
27
+ }));
28
+
29
+ function createWrapper(queryClient: QueryClient) {
30
+ return function Wrapper({ children }: { children: ReactNode }) {
31
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
32
+ };
33
+ }
34
+
35
+ describe('useChatSessionUpdate', () => {
36
+ afterEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('updates the session summary and invalidates the matching session skills queries', async () => {
41
+ const queryClient = new QueryClient();
42
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
43
+ const updatedSession = {
44
+ sessionId: 'session-1',
45
+ updatedAt: '2026-04-09T00:00:00.000Z',
46
+ status: 'idle',
47
+ metadata: { project_root: '/tmp/project-alpha' },
48
+ };
49
+ mocks.updateNcpSession.mockResolvedValue(updatedSession);
50
+
51
+ const { result } = renderHook(() => useChatSessionUpdate(), {
52
+ wrapper: createWrapper(queryClient),
53
+ });
54
+
55
+ await act(async () => {
56
+ await result.current({
57
+ sessionKey: 'session-1',
58
+ patch: { projectRoot: '/tmp/project-alpha' },
59
+ successMessage: 'Project directory updated',
60
+ });
61
+ });
62
+
63
+ expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-1', {
64
+ projectRoot: '/tmp/project-alpha',
65
+ });
66
+ expect(mocks.upsertNcpSessionSummaryInQueryClient).toHaveBeenCalledWith(
67
+ queryClient,
68
+ updatedSession,
69
+ );
70
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({
71
+ queryKey: ['ncp-session-skills', 'session-1'],
72
+ });
73
+ expect(toast.success).toHaveBeenCalledWith('Project directory updated');
74
+ });
75
+ });
@@ -15,10 +15,12 @@ export function useChatSessionUpdate() {
15
15
  const queryClient = useQueryClient();
16
16
 
17
17
  return async (params: UpdateChatSessionParams): Promise<void> => {
18
+ const { sessionKey, patch, successMessage } = params;
18
19
  try {
19
- const updated = await updateNcpSession(params.sessionKey, params.patch);
20
+ const updated = await updateNcpSession(sessionKey, patch);
20
21
  upsertNcpSessionSummaryInQueryClient(queryClient, updated);
21
- toast.success(params.successMessage ?? t('configSavedApplied'));
22
+ await queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', sessionKey] });
23
+ toast.success(successMessage ?? t('configSavedApplied'));
22
24
  } catch (error) {
23
25
  toast.error(
24
26
  t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)),
@@ -15,9 +15,12 @@ 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',
23
+ draftSessionKey: 'draft-root-1',
21
24
  listMode: 'time-first'
22
25
  }
23
26
  });
@@ -25,7 +28,7 @@ describe('ChatSessionListManager', () => {
25
28
 
26
29
  it('applies the requested session type when creating a session', () => {
27
30
  const uiManager = {
28
- goToChatRoot: vi.fn()
31
+ goToSession: vi.fn()
29
32
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
30
33
  const streamActionsManager = {
31
34
  resetStreamState: vi.fn()
@@ -35,8 +38,9 @@ describe('ChatSessionListManager', () => {
35
38
  manager.createSession('codex');
36
39
 
37
40
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
38
- expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
39
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
41
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
42
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
43
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
40
44
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
41
45
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
42
46
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
@@ -44,7 +48,7 @@ describe('ChatSessionListManager', () => {
44
48
 
45
49
  it('hydrates the draft project root when creating a session inside a project group', () => {
46
50
  const uiManager = {
47
- goToChatRoot: vi.fn()
51
+ goToSession: vi.fn()
48
52
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
49
53
  const streamActionsManager = {
50
54
  resetStreamState: vi.fn()
@@ -54,7 +58,45 @@ describe('ChatSessionListManager', () => {
54
58
  manager.createSession('native', '/tmp/project-alpha');
55
59
 
56
60
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
57
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
61
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
62
+ });
63
+
64
+ it('promotes the current root draft when send flow needs a concrete session key', () => {
65
+ useChatSessionListStore.setState({
66
+ snapshot: {
67
+ ...useChatSessionListStore.getState().snapshot,
68
+ selectedSessionKey: null,
69
+ draftSessionKey: 'draft-root-2'
70
+ }
71
+ });
72
+ const uiManager = {
73
+ goToSession: vi.fn()
74
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
75
+ const streamActionsManager = {
76
+ resetStreamState: vi.fn()
77
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
78
+
79
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
80
+ const sessionKey = manager.ensureDraftSession('native');
81
+
82
+ expect(sessionKey).toBe('draft-root-2');
83
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2');
84
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
85
+ });
86
+
87
+ it('does not eagerly replace the old selected session before the route finishes switching', () => {
88
+ const uiManager = {
89
+ goToSession: vi.fn()
90
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
91
+ const streamActionsManager = {
92
+ resetStreamState: vi.fn()
93
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
94
+
95
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
96
+ manager.createSession('native', '/tmp/project-alpha');
97
+
98
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
99
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
58
100
  });
59
101
 
60
102
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
@@ -82,4 +124,36 @@ describe('ChatSessionListManager', () => {
82
124
  expect(useChatSessionListStore.getState().snapshot.listMode).toBe('project-first');
83
125
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
84
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
+ });
85
159
  });