@nextclaw/ui 0.12.5 → 0.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/assets/ChannelsList-D8p4OlM6.js +8 -0
- package/dist/assets/ChatPage-A45t1Rmf.js +58 -0
- package/dist/assets/DocBrowser-B2MpsnU9.js +1 -0
- package/dist/assets/{DocBrowser-QUZ3nfmH.js → DocBrowser-Cse_F8Nn.js} +1 -1
- package/dist/assets/{DocBrowserContext-CpiIfhJO.js → DocBrowserContext-Bai1WU2H.js} +1 -1
- package/dist/assets/{LogoBadge-BUK13xK5.js → LogoBadge-BdxMPc9v.js} +1 -1
- package/dist/assets/MarketplacePage-BNZ3Jx5d.js +1 -0
- package/dist/assets/MarketplacePage-BbpAkllU.js +49 -0
- package/dist/assets/{McpMarketplacePage-BG4T_Pcx.js → McpMarketplacePage-CxPFOgxv.js} +2 -2
- package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
- package/dist/assets/{ProviderScopedModelInput-DGn6sFEN.js → ProviderScopedModelInput-BYNouw-i.js} +1 -1
- package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
- package/dist/assets/{RemoteAccessPage-ff15qO-c.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
- package/dist/assets/{RuntimeConfig-TgPandXF.js → RuntimeConfig-ChdfK4Y_.js} +1 -1
- package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
- package/dist/assets/{SecretsConfig-Bew4EF2A.js → SecretsConfig-CCYO6NcV.js} +2 -2
- package/dist/assets/SessionsConfig-Du39vDgt.js +2 -0
- package/dist/assets/app-query-client-Dr5d-K8d.js +1 -0
- package/dist/assets/{book-open-CJG8Yz3U.js → book-open-Da4OEPqB.js} +1 -1
- package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-D5b3Iyas.js → chunk-JZWAC4HX-CoFVxHXV.js} +1 -1
- package/dist/assets/client-CSk58DcF.js +7 -0
- package/dist/assets/config-D8KzikVB.js +1 -0
- package/dist/assets/{createLucideIcon-_FMJqZw2.js → createLucideIcon-83gaZMtv.js} +1 -1
- package/dist/assets/desktop-update-config-CfoVwf-w.js +1 -0
- package/dist/assets/dist-aTmhMDVh.js +9 -0
- package/dist/assets/{dist-B1fpOuON.js → dist-toEYs-MZ.js} +1 -1
- package/dist/assets/{external-link-b7gAJWYY.js → external-link-QQ0TC6X4.js} +1 -1
- package/dist/assets/{hash-Bhy4TwfZ.js → hash-DaFBEkmi.js} +1 -1
- package/dist/assets/i18n-C3jb83S6.js +1 -0
- package/dist/assets/index-CE4N7ItL.css +1 -0
- package/dist/assets/index-riX7Sg0_.js +6 -0
- package/dist/assets/infiniteQueryBehavior-BmHX_ayZ.js +1 -0
- package/dist/assets/loader-circle-BjMg63eu.js +1 -0
- package/dist/assets/{logos-GMeYU9vc.js → logos-Dzlz30M3.js} +1 -1
- package/dist/assets/{page-layout-C8UbWuMt.js → page-layout-D2eRufRQ.js} +1 -1
- package/dist/assets/plus-CIXME2pD.js +1 -0
- package/dist/assets/{popover-8HSx9wQj.js → popover-BSXxm5bj.js} +1 -1
- package/dist/assets/{refresh-ccw-CA4_C7Zg.js → refresh-ccw-B3zMtN-_.js} +1 -1
- package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
- package/dist/assets/{save-BtvMy4lk.js → save-Us9fg4Sj.js} +1 -1
- package/dist/assets/search-B_Qr0f6C.js +1 -0
- package/dist/assets/security-config-BGWYwxNr.js +1 -0
- package/dist/assets/{select-xp_Ac8ip.js → select-DLYqySQK.js} +1 -1
- package/dist/assets/skeleton-CYQJazv6.js +1 -0
- package/dist/assets/{status-dot-Cn4Pp7DZ.js → status-dot-DGayudyB.js} +1 -1
- package/dist/assets/{switch-BTi6UOij.js → switch-Dz2ScsKx.js} +1 -1
- package/dist/assets/{tabs-custom-BiiN8DME.js → tabs-custom-CdKyjiGk.js} +1 -1
- package/dist/assets/{trash-2-BpsF0N-r.js → trash-2-Db-mZOZs.js} +1 -1
- package/dist/assets/use-infinite-scroll-loader-DBJX5hj0.js +1 -0
- package/dist/assets/{useConfirmDialog-BJIwUZjH.js → useConfirmDialog-DL0a-oGC.js} +1 -1
- package/dist/assets/useMutation-BdZm-9PL.js +1 -0
- package/dist/assets/x-B8Tho_xC.js +1 -0
- package/dist/index.html +20 -19
- package/package.json +3 -3
- package/src/App.tsx +2 -0
- package/src/api/raw-client.test.ts +37 -0
- package/src/api/raw-client.ts +51 -8
- package/src/components/chat/ChatConversationPanel.test.tsx +161 -1
- package/src/components/chat/ChatSidebar.test.tsx +109 -4
- package/src/components/chat/ChatSidebar.tsx +62 -9
- package/src/components/chat/chat-child-session-panel.tsx +56 -18
- package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
- package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
- package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +13 -0
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +18 -5
- package/src/components/chat/stores/chat-session-list.store.ts +96 -5
- package/src/components/config/ProviderForm.tsx +9 -15
- package/src/components/config/desktop-update-config.tsx +230 -0
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/layout/sidebar.layout.test.tsx +1 -0
- package/src/desktop/desktop-update.types.ts +36 -0
- package/src/desktop/managers/desktop-update.manager.ts +163 -0
- package/src/desktop/stores/desktop-update.store.ts +18 -0
- package/src/lib/desktop-update-labels.utils.ts +72 -0
- package/src/lib/i18n.chat.ts +13 -0
- package/src/lib/i18n.ts +3 -9
- package/src/lib/ui-document-title.ts +1 -0
- package/src/transport/local.transport.ts +57 -18
- package/src/vite-env.d.ts +10 -0
- package/dist/assets/ChannelsList-C6-lh55g.js +0 -8
- package/dist/assets/ChatPage-DOW0gPc2.js +0 -45
- package/dist/assets/DocBrowser-CGyeswYP.js +0 -1
- package/dist/assets/MarketplacePage-BDVwhIYE.js +0 -1
- package/dist/assets/MarketplacePage-LnKKL3xK.js +0 -49
- package/dist/assets/ModelConfig-LtWuogIw.js +0 -1
- package/dist/assets/ProvidersList-ma-_MlLo.js +0 -1
- package/dist/assets/SearchConfig-C9iBt7pl.js +0 -1
- package/dist/assets/SessionsConfig-2r2yAGZg.js +0 -2
- package/dist/assets/chat-session-display-DkAC5OMC.js +0 -1
- package/dist/assets/config-zvnxSXSP.js +0 -1
- package/dist/assets/dist-BCXX7FD-.js +0 -15
- package/dist/assets/i18n-DJg9BPYk.js +0 -1
- package/dist/assets/index-BoJbxdvZ.css +0 -1
- package/dist/assets/index-CtlT4E9Y.js +0 -6
- package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +0 -1
- package/dist/assets/loader-circle-B60I0hEk.js +0 -1
- package/dist/assets/plus-CR7RfK3H.js +0 -1
- package/dist/assets/react-BB4jko2M.js +0 -1
- package/dist/assets/search-C60UA27E.js +0 -1
- package/dist/assets/security-config-BkFDYZ6j.js +0 -1
- package/dist/assets/skeleton-uxz_5h3A.js +0 -1
- package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +0 -1
- package/dist/assets/useMutation-BjBOKHj_.js +0 -1
- package/dist/assets/x-BfTu-g7D.js +0 -1
- package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
- /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
- /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
package/src/api/raw-client.ts
CHANGED
|
@@ -25,6 +25,31 @@ function inferNonJsonHint(endpoint: string, status: number): string | undefined
|
|
|
25
25
|
return undefined;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function formatUnknownFetchError(error: unknown): {
|
|
29
|
+
summary: string;
|
|
30
|
+
details: Record<string, unknown>;
|
|
31
|
+
} {
|
|
32
|
+
if (error instanceof Error) {
|
|
33
|
+
const name = error.name?.trim() || 'Error';
|
|
34
|
+
const message = error.message?.trim() || 'Unknown error';
|
|
35
|
+
return {
|
|
36
|
+
summary: `${name}: ${message}`,
|
|
37
|
+
details: {
|
|
38
|
+
errorName: name,
|
|
39
|
+
errorMessage: message,
|
|
40
|
+
...(error.stack?.trim() ? { errorStack: error.stack.trim() } : {})
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
summary: String(error ?? 'Unknown error'),
|
|
46
|
+
details: {
|
|
47
|
+
errorName: 'NonError',
|
|
48
|
+
errorMessage: String(error ?? 'Unknown error')
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
export async function requestRawApiResponse<T>(
|
|
29
54
|
endpoint: string,
|
|
30
55
|
options: RequestInit = {}
|
|
@@ -32,14 +57,32 @@ export async function requestRawApiResponse<T>(
|
|
|
32
57
|
const url = `${API_BASE}${endpoint}`;
|
|
33
58
|
const method = (options.method || 'GET').toUpperCase();
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
let response: Response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetch(url, {
|
|
63
|
+
credentials: 'include',
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
...options.headers
|
|
67
|
+
},
|
|
68
|
+
...options
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const formatted = formatUnknownFetchError(error);
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: {
|
|
75
|
+
code: 'NETWORK_ERROR',
|
|
76
|
+
message: `Fetch failed on ${method} ${endpoint} | ${formatted.summary}`,
|
|
77
|
+
details: {
|
|
78
|
+
method,
|
|
79
|
+
endpoint,
|
|
80
|
+
url,
|
|
81
|
+
...formatted.details
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
43
86
|
|
|
44
87
|
const text = await response.text();
|
|
45
88
|
let data: ApiResponse<T> | null = null;
|
|
@@ -3,7 +3,9 @@ import userEvent from "@testing-library/user-event";
|
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { ChatChildSessionPanel } from "@/components/chat/chat-child-session-panel";
|
|
5
5
|
import { ChatConversationPanel } from "@/components/chat/ChatConversationPanel";
|
|
6
|
+
import type { ResolvedChildSessionTab } from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
|
|
6
7
|
import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
|
|
8
|
+
import { useChatSessionListStore } from "@/components/chat/stores/chat-session-list.store";
|
|
7
9
|
import { useChatThreadStore } from "@/components/chat/stores/chat-thread.store";
|
|
8
10
|
|
|
9
11
|
const mocks = vi.hoisted(() => ({
|
|
@@ -21,12 +23,13 @@ const mocks = vi.hoisted(() => ({
|
|
|
21
23
|
parentSessionKey: "parent-session-1",
|
|
22
24
|
title: "北京天气",
|
|
23
25
|
agentId: "weather",
|
|
26
|
+
updatedAt: "2026-04-10T09:00:00.000Z",
|
|
24
27
|
sessionTypeLabel: "Codex",
|
|
25
28
|
preferredModel: "openai/gpt-5.3-codex",
|
|
26
29
|
projectName: "project-alpha",
|
|
27
30
|
projectRoot: "/Users/demo/project-alpha",
|
|
28
31
|
},
|
|
29
|
-
],
|
|
32
|
+
] as ResolvedChildSessionTab[],
|
|
30
33
|
}));
|
|
31
34
|
|
|
32
35
|
vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => {
|
|
@@ -79,6 +82,19 @@ vi.mock("@/components/chat/presenter/chat-presenter-context", () => ({
|
|
|
79
82
|
selectSession: vi.fn(),
|
|
80
83
|
createSession: mocks.createSession,
|
|
81
84
|
setSelectedAgentId: mocks.setSelectedAgentId,
|
|
85
|
+
markSessionRead: (
|
|
86
|
+
sessionKey: string | null | undefined,
|
|
87
|
+
updatedAt: string | null | undefined,
|
|
88
|
+
) =>
|
|
89
|
+
sessionKey
|
|
90
|
+
? useChatSessionListStore.getState().markSessionRead(
|
|
91
|
+
sessionKey,
|
|
92
|
+
updatedAt,
|
|
93
|
+
)
|
|
94
|
+
: undefined,
|
|
95
|
+
hydrateReadWatermarks: (
|
|
96
|
+
entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
|
|
97
|
+
) => useChatSessionListStore.getState().hydrateReadWatermarks(entries),
|
|
82
98
|
},
|
|
83
99
|
chatInputManager: {
|
|
84
100
|
setPendingSessionType: mocks.setPendingSessionType,
|
|
@@ -175,6 +191,13 @@ describe("ChatConversationPanel", () => {
|
|
|
175
191
|
activeChildSessionKey: null,
|
|
176
192
|
},
|
|
177
193
|
});
|
|
194
|
+
useChatSessionListStore.setState({
|
|
195
|
+
readUpdatedAtBySessionKey: {},
|
|
196
|
+
hasHydratedReadWatermarks: false,
|
|
197
|
+
snapshot: {
|
|
198
|
+
...useChatSessionListStore.getState().snapshot,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
178
201
|
});
|
|
179
202
|
|
|
180
203
|
it("shows the draft session type in the conversation header", () => {
|
|
@@ -275,6 +298,7 @@ describe("ChatChildSessionPanel", () => {
|
|
|
275
298
|
parentSessionKey: "parent-session-1",
|
|
276
299
|
title: "北京天气",
|
|
277
300
|
agentId: "weather",
|
|
301
|
+
updatedAt: "2026-04-10T09:00:00.000Z",
|
|
278
302
|
sessionTypeLabel: "Codex",
|
|
279
303
|
preferredModel: "openai/gpt-5.3-codex",
|
|
280
304
|
projectName: "project-alpha",
|
|
@@ -320,6 +344,7 @@ describe("ChatChildSessionPanel", () => {
|
|
|
320
344
|
parentSessionKey: "parent-session-1",
|
|
321
345
|
title: "北京天气",
|
|
322
346
|
agentId: "weather",
|
|
347
|
+
updatedAt: "2026-04-10T09:00:00.000Z",
|
|
323
348
|
sessionTypeLabel: "Codex",
|
|
324
349
|
preferredModel: "openai/gpt-5.3-codex",
|
|
325
350
|
projectName: "project-alpha",
|
|
@@ -330,6 +355,7 @@ describe("ChatChildSessionPanel", () => {
|
|
|
330
355
|
parentSessionKey: "parent-session-1",
|
|
331
356
|
title: "上海天气",
|
|
332
357
|
agentId: "weather",
|
|
358
|
+
updatedAt: "2026-04-10T09:05:00.000Z",
|
|
333
359
|
sessionTypeLabel: "Claude Code",
|
|
334
360
|
preferredModel: "anthropic/claude-sonnet-4",
|
|
335
361
|
projectName: "project-beta",
|
|
@@ -373,4 +399,138 @@ describe("ChatChildSessionPanel", () => {
|
|
|
373
399
|
expect(tabButtons[0]?.getAttribute("aria-pressed")).toBe("true");
|
|
374
400
|
expect(tabButtons[1]?.getAttribute("aria-pressed")).toBe("false");
|
|
375
401
|
});
|
|
402
|
+
|
|
403
|
+
it("shows an unread dot for inactive child tabs until the user opens them", () => {
|
|
404
|
+
mocks.resolvedChildTabs = [
|
|
405
|
+
{
|
|
406
|
+
sessionKey: "child-session-1",
|
|
407
|
+
parentSessionKey: "parent-session-1",
|
|
408
|
+
title: "北京天气",
|
|
409
|
+
agentId: "weather",
|
|
410
|
+
updatedAt: "2026-04-10T09:00:00.000Z",
|
|
411
|
+
sessionTypeLabel: "Codex",
|
|
412
|
+
preferredModel: "openai/gpt-5.3-codex",
|
|
413
|
+
projectName: "project-alpha",
|
|
414
|
+
projectRoot: "/Users/demo/project-alpha",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
sessionKey: "child-session-2",
|
|
418
|
+
parentSessionKey: "parent-session-1",
|
|
419
|
+
title: "上海天气",
|
|
420
|
+
agentId: "weather",
|
|
421
|
+
updatedAt: "2026-04-10T09:05:00.000Z",
|
|
422
|
+
runStatus: "running",
|
|
423
|
+
sessionTypeLabel: "Claude Code",
|
|
424
|
+
preferredModel: "anthropic/claude-sonnet-4",
|
|
425
|
+
projectName: "project-beta",
|
|
426
|
+
projectRoot: "/Users/demo/project-beta",
|
|
427
|
+
},
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const { rerender } = render(
|
|
431
|
+
<ChatChildSessionPanel
|
|
432
|
+
tabs={[
|
|
433
|
+
{
|
|
434
|
+
sessionKey: "child-session-1",
|
|
435
|
+
parentSessionKey: "parent-session-1",
|
|
436
|
+
label: "北京天气",
|
|
437
|
+
agentId: "weather",
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
sessionKey: "child-session-2",
|
|
441
|
+
parentSessionKey: "parent-session-1",
|
|
442
|
+
label: "上海天气",
|
|
443
|
+
agentId: "weather",
|
|
444
|
+
},
|
|
445
|
+
]}
|
|
446
|
+
activeSessionKey="child-session-1"
|
|
447
|
+
onSelectSession={vi.fn()}
|
|
448
|
+
onClose={vi.fn()}
|
|
449
|
+
onBackToParent={vi.fn()}
|
|
450
|
+
/>,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
expect(
|
|
454
|
+
screen.queryByLabelText("Session has unread updates"),
|
|
455
|
+
).toBeNull();
|
|
456
|
+
|
|
457
|
+
mocks.resolvedChildTabs = [
|
|
458
|
+
{
|
|
459
|
+
sessionKey: "child-session-1",
|
|
460
|
+
parentSessionKey: "parent-session-1",
|
|
461
|
+
title: "北京天气",
|
|
462
|
+
agentId: "weather",
|
|
463
|
+
updatedAt: "2026-04-10T09:00:00.000Z",
|
|
464
|
+
sessionTypeLabel: "Codex",
|
|
465
|
+
preferredModel: "openai/gpt-5.3-codex",
|
|
466
|
+
projectName: "project-alpha",
|
|
467
|
+
projectRoot: "/Users/demo/project-alpha",
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
sessionKey: "child-session-2",
|
|
471
|
+
parentSessionKey: "parent-session-1",
|
|
472
|
+
title: "上海天气",
|
|
473
|
+
agentId: "weather",
|
|
474
|
+
updatedAt: "2026-04-10T09:05:00.000Z",
|
|
475
|
+
sessionTypeLabel: "Claude Code",
|
|
476
|
+
preferredModel: "anthropic/claude-sonnet-4",
|
|
477
|
+
projectName: "project-beta",
|
|
478
|
+
projectRoot: "/Users/demo/project-beta",
|
|
479
|
+
},
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
rerender(
|
|
483
|
+
<ChatChildSessionPanel
|
|
484
|
+
tabs={[
|
|
485
|
+
{
|
|
486
|
+
sessionKey: "child-session-1",
|
|
487
|
+
parentSessionKey: "parent-session-1",
|
|
488
|
+
label: "北京天气",
|
|
489
|
+
agentId: "weather",
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
sessionKey: "child-session-2",
|
|
493
|
+
parentSessionKey: "parent-session-1",
|
|
494
|
+
label: "上海天气",
|
|
495
|
+
agentId: "weather",
|
|
496
|
+
},
|
|
497
|
+
]}
|
|
498
|
+
activeSessionKey="child-session-1"
|
|
499
|
+
onSelectSession={vi.fn()}
|
|
500
|
+
onClose={vi.fn()}
|
|
501
|
+
onBackToParent={vi.fn()}
|
|
502
|
+
/>,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
expect(
|
|
506
|
+
screen.getByLabelText("Session has unread updates"),
|
|
507
|
+
).toBeTruthy();
|
|
508
|
+
|
|
509
|
+
rerender(
|
|
510
|
+
<ChatChildSessionPanel
|
|
511
|
+
tabs={[
|
|
512
|
+
{
|
|
513
|
+
sessionKey: "child-session-1",
|
|
514
|
+
parentSessionKey: "parent-session-1",
|
|
515
|
+
label: "北京天气",
|
|
516
|
+
agentId: "weather",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
sessionKey: "child-session-2",
|
|
520
|
+
parentSessionKey: "parent-session-1",
|
|
521
|
+
label: "上海天气",
|
|
522
|
+
agentId: "weather",
|
|
523
|
+
},
|
|
524
|
+
]}
|
|
525
|
+
activeSessionKey="child-session-2"
|
|
526
|
+
onSelectSession={vi.fn()}
|
|
527
|
+
onClose={vi.fn()}
|
|
528
|
+
onBackToParent={vi.fn()}
|
|
529
|
+
/>,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
expect(
|
|
533
|
+
screen.queryByLabelText("Session has unread updates"),
|
|
534
|
+
).toBeNull();
|
|
535
|
+
});
|
|
376
536
|
});
|
|
@@ -18,8 +18,11 @@ const mocks = vi.hoisted(() => ({
|
|
|
18
18
|
isLoading: false
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
function createSessionItem(
|
|
22
|
-
|
|
21
|
+
function createSessionItem(
|
|
22
|
+
session: NcpSessionListItemView['session'],
|
|
23
|
+
runStatus?: NcpSessionListItemView['runStatus'],
|
|
24
|
+
): NcpSessionListItemView {
|
|
25
|
+
return { session, runStatus };
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
|
|
@@ -28,7 +31,12 @@ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
|
|
|
28
31
|
createSession: mocks.createSession,
|
|
29
32
|
setQuery: mocks.setQuery,
|
|
30
33
|
setListMode: mocks.setListMode,
|
|
31
|
-
selectSession: mocks.selectSession
|
|
34
|
+
selectSession: mocks.selectSession,
|
|
35
|
+
markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) =>
|
|
36
|
+
sessionKey ? useChatSessionListStore.getState().markSessionRead(sessionKey, updatedAt) : undefined,
|
|
37
|
+
hydrateReadWatermarks: (
|
|
38
|
+
entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
|
|
39
|
+
) => useChatSessionListStore.getState().hydrateReadWatermarks(entries)
|
|
32
40
|
}
|
|
33
41
|
})
|
|
34
42
|
}));
|
|
@@ -124,10 +132,13 @@ function resetSidebarTestState() {
|
|
|
124
132
|
}
|
|
125
133
|
});
|
|
126
134
|
useChatSessionListStore.setState({
|
|
135
|
+
readUpdatedAtBySessionKey: {},
|
|
136
|
+
hasHydratedReadWatermarks: false,
|
|
127
137
|
snapshot: {
|
|
128
138
|
...useChatSessionListStore.getState().snapshot,
|
|
129
139
|
query: '',
|
|
130
|
-
listMode: 'time-first'
|
|
140
|
+
listMode: 'time-first',
|
|
141
|
+
selectedSessionKey: null
|
|
131
142
|
}
|
|
132
143
|
});
|
|
133
144
|
}
|
|
@@ -496,4 +507,98 @@ describe('ChatSidebar session item interactions', () => {
|
|
|
496
507
|
expect(screen.queryByDisplayValue('Should Not Persist')).toBeNull();
|
|
497
508
|
expect(screen.getByText('Cancelable Label')).not.toBeNull();
|
|
498
509
|
});
|
|
510
|
+
|
|
511
|
+
it('shows an unread dot only after a non-active session finishes its newer update', () => {
|
|
512
|
+
mocks.sessionItems = [
|
|
513
|
+
createSessionItem({
|
|
514
|
+
key: 'session:ncp-1',
|
|
515
|
+
createdAt: '2026-03-19T09:00:00.000Z',
|
|
516
|
+
updatedAt: '2026-03-19T09:05:00.000Z',
|
|
517
|
+
label: 'Current Task',
|
|
518
|
+
sessionType: 'native',
|
|
519
|
+
sessionTypeMutable: false,
|
|
520
|
+
messageCount: 1
|
|
521
|
+
}),
|
|
522
|
+
createSessionItem({
|
|
523
|
+
key: 'session:ncp-2',
|
|
524
|
+
createdAt: '2026-03-19T10:00:00.000Z',
|
|
525
|
+
updatedAt: '2026-03-19T10:05:00.000Z',
|
|
526
|
+
label: 'Background Task',
|
|
527
|
+
sessionType: 'native',
|
|
528
|
+
sessionTypeMutable: false,
|
|
529
|
+
messageCount: 1
|
|
530
|
+
}, 'running')
|
|
531
|
+
];
|
|
532
|
+
useChatSessionListStore.setState({
|
|
533
|
+
snapshot: {
|
|
534
|
+
...useChatSessionListStore.getState().snapshot,
|
|
535
|
+
selectedSessionKey: 'session:ncp-1'
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const { rerender } = render(
|
|
540
|
+
<MemoryRouter>
|
|
541
|
+
<ChatSidebar />
|
|
542
|
+
</MemoryRouter>
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
|
|
546
|
+
|
|
547
|
+
mocks.sessionItems = [
|
|
548
|
+
mocks.sessionItems[0]!,
|
|
549
|
+
createSessionItem({
|
|
550
|
+
key: 'session:ncp-2',
|
|
551
|
+
createdAt: '2026-03-19T10:00:00.000Z',
|
|
552
|
+
updatedAt: '2026-03-19T10:06:00.000Z',
|
|
553
|
+
label: 'Background Task',
|
|
554
|
+
sessionType: 'native',
|
|
555
|
+
sessionTypeMutable: false,
|
|
556
|
+
messageCount: 2
|
|
557
|
+
}, 'running')
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
rerender(
|
|
561
|
+
<MemoryRouter>
|
|
562
|
+
<ChatSidebar />
|
|
563
|
+
</MemoryRouter>
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
|
|
567
|
+
|
|
568
|
+
mocks.sessionItems = [
|
|
569
|
+
mocks.sessionItems[0]!,
|
|
570
|
+
createSessionItem({
|
|
571
|
+
key: 'session:ncp-2',
|
|
572
|
+
createdAt: '2026-03-19T10:00:00.000Z',
|
|
573
|
+
updatedAt: '2026-03-19T10:06:00.000Z',
|
|
574
|
+
label: 'Background Task',
|
|
575
|
+
sessionType: 'native',
|
|
576
|
+
sessionTypeMutable: false,
|
|
577
|
+
messageCount: 2
|
|
578
|
+
})
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
rerender(
|
|
582
|
+
<MemoryRouter>
|
|
583
|
+
<ChatSidebar />
|
|
584
|
+
</MemoryRouter>
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
expect(screen.getByLabelText('Session has unread updates')).toBeTruthy();
|
|
588
|
+
|
|
589
|
+
useChatSessionListStore.setState({
|
|
590
|
+
snapshot: {
|
|
591
|
+
...useChatSessionListStore.getState().snapshot,
|
|
592
|
+
selectedSessionKey: 'session:ncp-2'
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
rerender(
|
|
597
|
+
<MemoryRouter>
|
|
598
|
+
<ChatSidebar />
|
|
599
|
+
</MemoryRouter>
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
|
|
603
|
+
});
|
|
499
604
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import type { SessionEntryView } from '@/api/types';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
4
|
import { BrandHeader } from '@/components/common/BrandHeader';
|
|
@@ -17,7 +17,10 @@ import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-la
|
|
|
17
17
|
import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
|
|
18
18
|
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
19
19
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
shouldShowUnreadSessionIndicator,
|
|
22
|
+
useChatSessionListStore
|
|
23
|
+
} from '@/components/chat/stores/chat-session-list.store';
|
|
21
24
|
import { useAgents } from '@/hooks/agents/useAgents';
|
|
22
25
|
import { getSessionProjectName } from '@/lib/session-project/session-project.utils';
|
|
23
26
|
import { cn } from '@/lib/utils';
|
|
@@ -143,6 +146,50 @@ const navItems = [
|
|
|
143
146
|
{ target: '/agents', label: () => t('agentsPageTitle'), icon: Bot },
|
|
144
147
|
];
|
|
145
148
|
|
|
149
|
+
function useChatSessionUnreadState(
|
|
150
|
+
items: readonly NcpSessionListItemView[],
|
|
151
|
+
selectedSessionKey: string | null,
|
|
152
|
+
markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => void,
|
|
153
|
+
hydrateReadWatermarks: (
|
|
154
|
+
entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
|
|
155
|
+
) => void,
|
|
156
|
+
): Record<string, string> {
|
|
157
|
+
const readUpdatedAtBySessionKey = useChatSessionListStore((state) => state.readUpdatedAtBySessionKey);
|
|
158
|
+
const hasHydratedReadWatermarks = useChatSessionListStore((state) => state.hasHydratedReadWatermarks);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const syncHydratedReadWatermarks = () => {
|
|
162
|
+
if (hasHydratedReadWatermarks || items.length === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
hydrateReadWatermarks(
|
|
166
|
+
items.map(({ session }) => ({
|
|
167
|
+
sessionKey: session.key,
|
|
168
|
+
updatedAt: session.updatedAt
|
|
169
|
+
}))
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
syncHydratedReadWatermarks();
|
|
173
|
+
}, [hasHydratedReadWatermarks, hydrateReadWatermarks, items]);
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const syncSelectedSessionReadState = () => {
|
|
177
|
+
if (!selectedSessionKey) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const selectedItem = items.find(({ session }) => session.key === selectedSessionKey);
|
|
181
|
+
if (!selectedItem) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const { session: selectedSession } = selectedItem;
|
|
185
|
+
markSessionRead(selectedSession.key, selectedSession.updatedAt);
|
|
186
|
+
};
|
|
187
|
+
syncSelectedSessionReadState();
|
|
188
|
+
}, [items, markSessionRead, selectedSessionKey]);
|
|
189
|
+
|
|
190
|
+
return readUpdatedAtBySessionKey;
|
|
191
|
+
}
|
|
192
|
+
|
|
146
193
|
export function ChatSidebar() {
|
|
147
194
|
const presenter = usePresenter();
|
|
148
195
|
const docBrowser = useDocBrowser();
|
|
@@ -164,7 +211,6 @@ export function ChatSidebar() {
|
|
|
164
211
|
() => new Map((agentsQuery.data?.agents ?? []).map((agent) => [agent.id, agent])),
|
|
165
212
|
[agentsQuery.data?.agents]
|
|
166
213
|
);
|
|
167
|
-
|
|
168
214
|
const sortedItems = useMemo(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
|
|
169
215
|
const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
|
|
170
216
|
const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
|
|
@@ -174,24 +220,26 @@ export function ChatSidebar() {
|
|
|
174
220
|
[defaultSessionType, inputSnapshot.sessionTypeOptions]
|
|
175
221
|
);
|
|
176
222
|
const isProjectFirstView = listSnapshot.listMode === 'project-first';
|
|
177
|
-
|
|
223
|
+
const readUpdatedAtBySessionKey = useChatSessionUnreadState(
|
|
224
|
+
items,
|
|
225
|
+
listSnapshot.selectedSessionKey,
|
|
226
|
+
presenter.chatSessionListManager.markSessionRead,
|
|
227
|
+
presenter.chatSessionListManager.hydrateReadWatermarks,
|
|
228
|
+
);
|
|
178
229
|
const handleLanguageSwitch = (nextLang: I18nLanguage) => {
|
|
179
230
|
if (language === nextLang) return;
|
|
180
231
|
setLanguage(nextLang);
|
|
181
232
|
window.location.reload();
|
|
182
233
|
};
|
|
183
|
-
|
|
184
234
|
const startEditingSessionLabel = (session: SessionEntryView) => {
|
|
185
235
|
setEditingSessionKey(session.key);
|
|
186
236
|
setDraftLabel(session.label?.trim() ?? '');
|
|
187
237
|
};
|
|
188
|
-
|
|
189
238
|
const cancelEditingSessionLabel = () => {
|
|
190
239
|
setEditingSessionKey(null);
|
|
191
240
|
setDraftLabel('');
|
|
192
241
|
setSavingSessionKey(null);
|
|
193
242
|
};
|
|
194
|
-
|
|
195
243
|
const saveSessionLabel = async (session: SessionEntryView) => {
|
|
196
244
|
const normalizedLabel = draftLabel.trim();
|
|
197
245
|
const currentLabel = session.label?.trim() ?? '';
|
|
@@ -211,9 +259,14 @@ export function ChatSidebar() {
|
|
|
211
259
|
setSavingSessionKey(null);
|
|
212
260
|
}
|
|
213
261
|
};
|
|
214
|
-
|
|
215
262
|
const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
|
|
216
263
|
const active = listSnapshot.selectedSessionKey === session.key;
|
|
264
|
+
const showUnreadDot = shouldShowUnreadSessionIndicator({
|
|
265
|
+
active,
|
|
266
|
+
updatedAt: session.updatedAt,
|
|
267
|
+
readUpdatedAt: readUpdatedAtBySessionKey[session.key],
|
|
268
|
+
runStatus,
|
|
269
|
+
});
|
|
217
270
|
const context = resolveSessionContextView(session, inputSnapshot.sessionTypeOptions);
|
|
218
271
|
const isEditing = editingSessionKey === session.key;
|
|
219
272
|
const isSaving = savingSessionKey === session.key;
|
|
@@ -222,6 +275,7 @@ export function ChatSidebar() {
|
|
|
222
275
|
key={session.key}
|
|
223
276
|
session={session}
|
|
224
277
|
active={active}
|
|
278
|
+
showUnreadDot={showUnreadDot}
|
|
225
279
|
runStatus={runStatus}
|
|
226
280
|
context={context}
|
|
227
281
|
title={sessionTitle(session)}
|
|
@@ -239,7 +293,6 @@ export function ChatSidebar() {
|
|
|
239
293
|
/>
|
|
240
294
|
);
|
|
241
295
|
};
|
|
242
|
-
|
|
243
296
|
return (
|
|
244
297
|
<aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
|
|
245
298
|
<div className="px-5 pt-5 pb-3">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { ArrowLeft, Loader2, X } from "lucide-react";
|
|
3
3
|
import { useStickyBottomScroll } from "@nextclaw/agent-chat-ui";
|
|
4
4
|
import { ChatMessageListContainer } from "@/components/chat/containers/chat-message-list.container";
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
type ResolvedChildSessionTab,
|
|
8
8
|
} from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
|
|
9
9
|
import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
|
|
10
|
+
import {
|
|
11
|
+
shouldShowUnreadSessionIndicator,
|
|
12
|
+
useChatSessionListStore,
|
|
13
|
+
} from "@/components/chat/stores/chat-session-list.store";
|
|
14
|
+
import { usePresenter } from "@/components/chat/presenter/chat-presenter-context";
|
|
10
15
|
import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
|
|
11
16
|
import { AgentIdentityAvatar } from "@/components/common/agent-identity";
|
|
12
17
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
@@ -122,16 +127,35 @@ export function ChatChildSessionPanel({
|
|
|
122
127
|
onBackToParent,
|
|
123
128
|
onToolAction,
|
|
124
129
|
}: ChatChildSessionPanelProps) {
|
|
130
|
+
const presenter = usePresenter();
|
|
125
131
|
const resolvedTabs = useNcpChildSessionTabsView(tabs);
|
|
132
|
+
const readUpdatedAtBySessionKey = useChatSessionListStore(
|
|
133
|
+
(state) => state.readUpdatedAtBySessionKey,
|
|
134
|
+
);
|
|
126
135
|
const activeTab =
|
|
127
136
|
resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
|
|
128
137
|
resolvedTabs[0] ??
|
|
129
138
|
null;
|
|
139
|
+
const activeTabSessionKey = activeTab?.sessionKey ?? null;
|
|
140
|
+
const activeTabUpdatedAt = activeTab?.updatedAt?.trim() ?? null;
|
|
130
141
|
const hasParentSession = resolvedTabs.some((tab) =>
|
|
131
142
|
Boolean(tab.parentSessionKey),
|
|
132
143
|
);
|
|
133
144
|
const shouldShowTabs = resolvedTabs.length > 1;
|
|
134
145
|
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const syncActiveTabReadState = () => {
|
|
148
|
+
if (!activeTabSessionKey || !activeTabUpdatedAt) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
presenter.chatSessionListManager.markSessionRead(
|
|
152
|
+
activeTabSessionKey,
|
|
153
|
+
activeTabUpdatedAt,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
syncActiveTabReadState();
|
|
157
|
+
}, [activeTabSessionKey, activeTabUpdatedAt, presenter]);
|
|
158
|
+
|
|
135
159
|
if (!activeTab) {
|
|
136
160
|
return null;
|
|
137
161
|
}
|
|
@@ -178,23 +202,37 @@ export function ChatChildSessionPanel({
|
|
|
178
202
|
<div className="mt-3 overflow-x-auto custom-scrollbar">
|
|
179
203
|
<Tabs value={activeSessionKey} onValueChange={onSelectSession}>
|
|
180
204
|
<TabsList className="h-auto min-w-max justify-start gap-1.5 rounded-none bg-transparent p-0 text-gray-500">
|
|
181
|
-
{resolvedTabs.map((tab) =>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
{tab.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
{resolvedTabs.map((tab) => {
|
|
206
|
+
const showUnreadDot = shouldShowUnreadSessionIndicator({
|
|
207
|
+
active: tab.sessionKey === activeSessionKey,
|
|
208
|
+
updatedAt: tab.updatedAt,
|
|
209
|
+
readUpdatedAt: readUpdatedAtBySessionKey[tab.sessionKey],
|
|
210
|
+
runStatus: tab.runStatus,
|
|
211
|
+
});
|
|
212
|
+
return (
|
|
213
|
+
<TabsTrigger
|
|
214
|
+
key={tab.sessionKey}
|
|
215
|
+
value={tab.sessionKey}
|
|
216
|
+
className="gap-2 rounded-full border border-gray-200/80 bg-white/85 px-2.5 py-1.5 text-xs font-medium text-gray-600 shadow-none hover:border-primary/30 hover:text-primary data-[state=active]:border-primary/30 data-[state=active]:bg-primary-50/70 data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
|
217
|
+
>
|
|
218
|
+
{tab.agentId ? (
|
|
219
|
+
<AgentIdentityAvatar
|
|
220
|
+
agentId={tab.agentId}
|
|
221
|
+
className="h-4 w-4 shrink-0"
|
|
222
|
+
/>
|
|
223
|
+
) : null}
|
|
224
|
+
<span className="max-w-[132px] truncate">
|
|
225
|
+
{tab.title}
|
|
226
|
+
</span>
|
|
227
|
+
{showUnreadDot ? (
|
|
228
|
+
<span
|
|
229
|
+
aria-label={t("chatSessionUnread")}
|
|
230
|
+
className="h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
231
|
+
/>
|
|
232
|
+
) : null}
|
|
233
|
+
</TabsTrigger>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
198
236
|
</TabsList>
|
|
199
237
|
</Tabs>
|
|
200
238
|
</div>
|