@nextclaw/ui 0.12.6 → 0.12.8
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 +90 -0
- package/dist/assets/{ChannelsList-D8p4OlM6.js → ChannelsList-KIQIxluX.js} +1 -1
- package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-BMxf9CIK.js} +1 -1
- package/dist/assets/DocBrowser-CyDgAtO9.js +1 -0
- package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-Ce28gRXt.js} +1 -1
- package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-o92MOA2L.js} +1 -1
- package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-BySqkYDh.js} +1 -1
- package/dist/assets/MarketplacePage-C0olZaek.js +1 -0
- package/dist/assets/{McpMarketplacePage-CxPFOgxv.js → McpMarketplacePage-DqKaiXO9.js} +1 -1
- package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-IrmzoslW.js} +1 -1
- package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-CmTIzgI7.js} +1 -1
- package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-8_Kalfwl.js} +1 -1
- package/dist/assets/{RemoteAccessPage-DyYVWsyK.js → RemoteAccessPage-CyQlSjPf.js} +1 -1
- package/dist/assets/RuntimeConfig-Bk0uYBhf.js +1 -0
- package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-DNBR-UbE.js} +1 -1
- package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-Ba1RPJaG.js} +1 -1
- package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-Doqp5ghH.js} +1 -1
- package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-DniXoIN5.js} +1 -1
- package/dist/assets/{book-open-Da4OEPqB.js → book-open-DocgeQtR.js} +1 -1
- package/dist/assets/chat-page-Bph8M5zo.js +58 -0
- package/dist/assets/chat-session-display-CoN3Wmn-.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-BvKvh1R8.js} +1 -1
- package/dist/assets/{client-CSk58DcF.js → client-CVqPF5ie.js} +1 -1
- package/dist/assets/{config-D8KzikVB.js → config-Bop2oB18.js} +1 -1
- package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-DVv8taGY.js} +1 -1
- package/dist/assets/desktop-update-config-1KBrqLBC.js +1 -0
- package/dist/assets/{dist-toEYs-MZ.js → dist-Da5Gm_pO.js} +1 -1
- package/dist/assets/{dist-aTmhMDVh.js → dist-DmAlInRu.js} +1 -1
- package/dist/assets/{external-link-QQ0TC6X4.js → external-link-DFjw3x1B.js} +1 -1
- package/dist/assets/{hash-DaFBEkmi.js → hash-DJtaCejM.js} +1 -1
- package/dist/assets/i18n-CwHZ-9vt.js +1 -0
- package/dist/assets/{index-CE4N7ItL.css → index-DafCdM4F.css} +1 -1
- package/dist/assets/{index-riX7Sg0_.js → index-DdksE6U3.js} +3 -3
- package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-DHSEQ3OH.js} +1 -1
- package/dist/assets/loader-circle-PsSP0H9n.js +1 -0
- package/dist/assets/{logos-Dzlz30M3.js → logos-DEFUIR12.js} +1 -1
- package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-Da3i3r6G.js} +1 -1
- package/dist/assets/play-DBQbBxTA.js +1 -0
- package/dist/assets/plus-DUOVbsyQ.js +1 -0
- package/dist/assets/{popover-BSXxm5bj.js → popover-C_mWOFzI.js} +1 -1
- package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-D6HkNtfz.js} +1 -1
- package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-DRcvRrnc.js} +1 -1
- package/dist/assets/rotate-cw-BmDKfXtH.js +1 -0
- package/dist/assets/{save-Us9fg4Sj.js → save-DHGmi2e9.js} +1 -1
- package/dist/assets/search-MChQRYR1.js +1 -0
- package/dist/assets/{security-config-BGWYwxNr.js → security-config-CbXfPZzr.js} +1 -1
- package/dist/assets/{select-DLYqySQK.js → select-Caud8QvU.js} +1 -1
- package/dist/assets/skeleton-B-4vRq_Z.js +1 -0
- package/dist/assets/{status-dot-DGayudyB.js → status-dot-DurKKSwA.js} +1 -1
- package/dist/assets/{switch-Dz2ScsKx.js → switch-0rmPBRKI.js} +1 -1
- package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-5JLVL6v8.js} +1 -1
- package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-C6caKPoz.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-dwnaa_qi.js} +1 -1
- package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-mMeWD_yo.js} +1 -1
- package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-BmxxvCNf.js} +1 -1
- package/dist/assets/x-DuMhMATD.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +6 -6
- package/src/api/runtime-control.ts +34 -0
- package/src/api/runtime-control.types.ts +58 -0
- package/src/api/types.ts +13 -0
- package/src/{App.test.tsx → app.test.tsx} +1 -1
- package/src/{App.tsx → app.tsx} +1 -1
- package/src/components/chat/ChatConversationPanel.test.tsx +78 -16
- package/src/components/chat/ChatSidebar.test.tsx +36 -7
- package/src/components/chat/ChatSidebar.tsx +19 -26
- package/src/components/chat/chat-child-session-panel.tsx +16 -8
- package/src/components/chat/chat-page-runtime.test.ts +1 -1
- package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
- package/src/components/chat/managers/chat-session-list.manager.test.ts +82 -31
- package/src/components/chat/managers/chat-session-list.manager.ts +79 -14
- package/src/components/chat/managers/chat-ui.manager.ts +2 -0
- package/src/components/chat/ncp/README.md +1 -1
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +5 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +12 -0
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
- package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
- package/src/components/chat/stores/chat-session-list.store.ts +25 -54
- package/src/components/common/ProviderScopedModelInput.tsx +12 -2
- package/src/components/config/ModelConfig.test.tsx +108 -2
- package/src/components/config/RuntimeConfig.tsx +14 -6
- package/src/components/config/desktop-update-config.test.tsx +85 -0
- package/src/components/config/desktop-update-config.tsx +44 -3
- package/src/components/config/runtime-control-card.test.tsx +255 -0
- package/src/components/config/runtime-control-card.tsx +301 -0
- package/src/components/config/runtime-presence-card.test.tsx +154 -0
- package/src/components/config/runtime-presence-card.tsx +163 -0
- package/src/desktop/desktop-update.types.ts +25 -0
- package/src/desktop/managers/desktop-presence.manager.ts +91 -0
- package/src/desktop/managers/desktop-update.manager.ts +37 -1
- package/src/desktop/stores/desktop-presence.store.ts +18 -0
- package/src/desktop/stores/desktop-update.store.ts +7 -1
- package/src/hooks/use-runtime-control.ts +24 -0
- package/src/lib/desktop-update-labels.utils.ts +28 -2
- package/src/lib/i18n.runtime-control.ts +120 -0
- package/src/lib/i18n.ts +2 -4
- package/src/main.tsx +1 -1
- package/src/runtime-control/runtime-control.manager.ts +118 -0
- package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
- package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
- package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
- package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
- package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
- package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
- package/dist/assets/i18n-C3jb83S6.js +0 -1
- package/dist/assets/loader-circle-BjMg63eu.js +0 -1
- package/dist/assets/plus-CIXME2pD.js +0 -1
- package/dist/assets/search-B_Qr0f6C.js +0 -1
- package/dist/assets/skeleton-CYQJazv6.js +0 -1
- package/dist/assets/x-B8Tho_xC.js +0 -1
- /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BZoDjXye.js} +0 -0
- /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-DmlGaay2.js} +0 -0
- /package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +0 -0
|
@@ -129,15 +129,15 @@ export function ChatChildSessionPanel({
|
|
|
129
129
|
}: ChatChildSessionPanelProps) {
|
|
130
130
|
const presenter = usePresenter();
|
|
131
131
|
const resolvedTabs = useNcpChildSessionTabsView(tabs);
|
|
132
|
-
const
|
|
133
|
-
(state) => state.
|
|
132
|
+
const optimisticReadAtBySessionKey = useChatSessionListStore(
|
|
133
|
+
(state) => state.optimisticReadAtBySessionKey,
|
|
134
134
|
);
|
|
135
135
|
const activeTab =
|
|
136
136
|
resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
|
|
137
137
|
resolvedTabs[0] ??
|
|
138
138
|
null;
|
|
139
139
|
const activeTabSessionKey = activeTab?.sessionKey ?? null;
|
|
140
|
-
const
|
|
140
|
+
const activeTabReadAt = activeTab?.lastMessageAt?.trim() ?? null;
|
|
141
141
|
const hasParentSession = resolvedTabs.some((tab) =>
|
|
142
142
|
Boolean(tab.parentSessionKey),
|
|
143
143
|
);
|
|
@@ -145,16 +145,17 @@ export function ChatChildSessionPanel({
|
|
|
145
145
|
|
|
146
146
|
useEffect(() => {
|
|
147
147
|
const syncActiveTabReadState = () => {
|
|
148
|
-
if (!activeTabSessionKey || !
|
|
148
|
+
if (!activeTabSessionKey || !activeTabReadAt) {
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
151
|
presenter.chatSessionListManager.markSessionRead(
|
|
152
152
|
activeTabSessionKey,
|
|
153
|
-
|
|
153
|
+
activeTabReadAt,
|
|
154
|
+
activeTab?.readAt ?? null,
|
|
154
155
|
);
|
|
155
156
|
};
|
|
156
157
|
syncActiveTabReadState();
|
|
157
|
-
}, [
|
|
158
|
+
}, [activeTab?.readAt, activeTabReadAt, activeTabSessionKey, presenter]);
|
|
158
159
|
|
|
159
160
|
if (!activeTab) {
|
|
160
161
|
return null;
|
|
@@ -203,10 +204,17 @@ export function ChatChildSessionPanel({
|
|
|
203
204
|
<Tabs value={activeSessionKey} onValueChange={onSelectSession}>
|
|
204
205
|
<TabsList className="h-auto min-w-max justify-start gap-1.5 rounded-none bg-transparent p-0 text-gray-500">
|
|
205
206
|
{resolvedTabs.map((tab) => {
|
|
207
|
+
const optimisticReadAt = optimisticReadAtBySessionKey[tab.sessionKey];
|
|
208
|
+
const effectiveReadAt =
|
|
209
|
+
optimisticReadAt && tab.readAt
|
|
210
|
+
? (optimisticReadAt.localeCompare(tab.readAt) > 0
|
|
211
|
+
? optimisticReadAt
|
|
212
|
+
: tab.readAt)
|
|
213
|
+
: optimisticReadAt ?? tab.readAt;
|
|
206
214
|
const showUnreadDot = shouldShowUnreadSessionIndicator({
|
|
207
215
|
active: tab.sessionKey === activeSessionKey,
|
|
208
|
-
|
|
209
|
-
|
|
216
|
+
lastMessageAt: tab.lastMessageAt,
|
|
217
|
+
readAt: effectiveReadAt,
|
|
210
218
|
runStatus: tab.runStatus,
|
|
211
219
|
});
|
|
212
220
|
return (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChatPageProps } from '@/components/chat/chat-page-shell';
|
|
2
|
-
import { NcpChatPage } from '@/components/chat/ncp/
|
|
2
|
+
import { NcpChatPage } from '@/components/chat/ncp/ncp-chat-page';
|
|
3
3
|
|
|
4
4
|
export function ChatPage({ view }: ChatPageProps) {
|
|
5
5
|
return <NcpChatPage view={view} />;
|
|
@@ -3,8 +3,18 @@ import { ChatSessionListManager } from '@/components/chat/managers/chat-session-
|
|
|
3
3
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
4
4
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
5
5
|
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
updateNcpSession: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@/api/ncp-session', () => ({
|
|
11
|
+
updateNcpSession: mocks.updateNcpSession,
|
|
12
|
+
}));
|
|
13
|
+
|
|
6
14
|
describe('ChatSessionListManager', () => {
|
|
7
15
|
beforeEach(() => {
|
|
16
|
+
mocks.updateNcpSession.mockReset();
|
|
17
|
+
mocks.updateNcpSession.mockResolvedValue({});
|
|
8
18
|
useChatInputStore.setState({
|
|
9
19
|
snapshot: {
|
|
10
20
|
...useChatInputStore.getState().snapshot,
|
|
@@ -15,8 +25,7 @@ describe('ChatSessionListManager', () => {
|
|
|
15
25
|
}
|
|
16
26
|
});
|
|
17
27
|
useChatSessionListStore.setState({
|
|
18
|
-
|
|
19
|
-
hasHydratedReadWatermarks: false,
|
|
28
|
+
optimisticReadAtBySessionKey: {},
|
|
20
29
|
snapshot: {
|
|
21
30
|
...useChatSessionListStore.getState().snapshot,
|
|
22
31
|
selectedSessionKey: 'session-1',
|
|
@@ -28,18 +37,21 @@ describe('ChatSessionListManager', () => {
|
|
|
28
37
|
|
|
29
38
|
it('applies the requested session type when creating a session', () => {
|
|
30
39
|
const uiManager = {
|
|
31
|
-
|
|
40
|
+
goToChatRoot: vi.fn(),
|
|
41
|
+
goToSession: vi.fn(),
|
|
42
|
+
isAtChatRoot: vi.fn(() => true),
|
|
32
43
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
33
44
|
const streamActionsManager = {
|
|
34
45
|
resetStreamState: vi.fn()
|
|
35
46
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
36
47
|
|
|
37
48
|
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
38
|
-
manager.createSession('codex');
|
|
49
|
+
const sessionKey = manager.createSession('codex');
|
|
39
50
|
|
|
40
51
|
expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
|
|
41
|
-
expect(uiManager.
|
|
42
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).
|
|
52
|
+
expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
54
|
+
expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBe(sessionKey);
|
|
43
55
|
expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
|
|
44
56
|
expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
|
|
45
57
|
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
|
|
@@ -48,20 +60,22 @@ describe('ChatSessionListManager', () => {
|
|
|
48
60
|
|
|
49
61
|
it('hydrates the draft project root when creating a session inside a project group', () => {
|
|
50
62
|
const uiManager = {
|
|
51
|
-
|
|
63
|
+
goToChatRoot: vi.fn(),
|
|
64
|
+
goToSession: vi.fn(),
|
|
65
|
+
isAtChatRoot: vi.fn(() => true),
|
|
52
66
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
53
67
|
const streamActionsManager = {
|
|
54
68
|
resetStreamState: vi.fn()
|
|
55
69
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
56
70
|
|
|
57
71
|
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
58
|
-
manager.createSession('native', '/tmp/project-alpha');
|
|
72
|
+
const sessionKey = manager.createSession('native', '/tmp/project-alpha');
|
|
59
73
|
|
|
60
74
|
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
|
|
61
|
-
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(
|
|
75
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
|
|
62
76
|
});
|
|
63
77
|
|
|
64
|
-
it('
|
|
78
|
+
it('reuses the current root draft when send flow needs a concrete session key', () => {
|
|
65
79
|
useChatSessionListStore.setState({
|
|
66
80
|
snapshot: {
|
|
67
81
|
...useChatSessionListStore.getState().snapshot,
|
|
@@ -70,7 +84,9 @@ describe('ChatSessionListManager', () => {
|
|
|
70
84
|
}
|
|
71
85
|
});
|
|
72
86
|
const uiManager = {
|
|
73
|
-
|
|
87
|
+
goToChatRoot: vi.fn(),
|
|
88
|
+
goToSession: vi.fn(),
|
|
89
|
+
isAtChatRoot: vi.fn(() => true),
|
|
74
90
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
75
91
|
const streamActionsManager = {
|
|
76
92
|
resetStreamState: vi.fn()
|
|
@@ -80,28 +96,33 @@ describe('ChatSessionListManager', () => {
|
|
|
80
96
|
const sessionKey = manager.ensureDraftSession('native');
|
|
81
97
|
|
|
82
98
|
expect(sessionKey).toBe('draft-root-2');
|
|
83
|
-
expect(uiManager.
|
|
99
|
+
expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
|
|
100
|
+
expect(uiManager.goToSession).not.toHaveBeenCalled();
|
|
84
101
|
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
85
102
|
});
|
|
86
103
|
|
|
87
104
|
it('does not eagerly replace the old selected session before the route finishes switching', () => {
|
|
88
105
|
const uiManager = {
|
|
89
|
-
|
|
106
|
+
goToChatRoot: vi.fn(),
|
|
107
|
+
goToSession: vi.fn(),
|
|
108
|
+
isAtChatRoot: vi.fn(() => true),
|
|
90
109
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
91
110
|
const streamActionsManager = {
|
|
92
111
|
resetStreamState: vi.fn()
|
|
93
112
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
94
113
|
|
|
95
114
|
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
96
|
-
manager.createSession('native', '/tmp/project-alpha');
|
|
115
|
+
const sessionKey = manager.createSession('native', '/tmp/project-alpha');
|
|
97
116
|
|
|
98
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).
|
|
99
|
-
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(
|
|
117
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
118
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
|
|
100
119
|
});
|
|
101
120
|
|
|
102
121
|
it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
|
|
103
122
|
const uiManager = {
|
|
104
|
-
|
|
123
|
+
goToChatRoot: vi.fn(),
|
|
124
|
+
goToSession: vi.fn(),
|
|
125
|
+
isAtChatRoot: vi.fn(() => true),
|
|
105
126
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
106
127
|
const streamActionsManager = {
|
|
107
128
|
resetStreamState: vi.fn()
|
|
@@ -115,7 +136,9 @@ describe('ChatSessionListManager', () => {
|
|
|
115
136
|
});
|
|
116
137
|
|
|
117
138
|
it('updates the sidebar list mode without touching other session list state', () => {
|
|
118
|
-
const uiManager = {
|
|
139
|
+
const uiManager = {
|
|
140
|
+
isAtChatRoot: vi.fn(() => true),
|
|
141
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
119
142
|
const streamActionsManager = {} as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
120
143
|
|
|
121
144
|
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
@@ -127,33 +150,61 @@ describe('ChatSessionListManager', () => {
|
|
|
127
150
|
|
|
128
151
|
it('marks a session as read through the session list owner boundary', () => {
|
|
129
152
|
const manager = new ChatSessionListManager(
|
|
130
|
-
{
|
|
153
|
+
{
|
|
154
|
+
isAtChatRoot: vi.fn(() => true),
|
|
155
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
|
|
131
156
|
{} as ConstructorParameters<typeof ChatSessionListManager>[1]
|
|
132
157
|
);
|
|
133
158
|
|
|
134
159
|
manager.markSessionRead('session-2', '2026-04-10T10:00:00.000Z');
|
|
135
160
|
|
|
136
|
-
expect(useChatSessionListStore.getState().
|
|
161
|
+
expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBe(
|
|
137
162
|
'2026-04-10T10:00:00.000Z'
|
|
138
163
|
);
|
|
164
|
+
expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-2', {
|
|
165
|
+
uiReadAt: '2026-04-10T10:00:00.000Z'
|
|
166
|
+
});
|
|
139
167
|
});
|
|
140
168
|
|
|
141
|
-
it('
|
|
169
|
+
it('skips persisting read state when the backend already has the same watermark', () => {
|
|
142
170
|
const manager = new ChatSessionListManager(
|
|
143
|
-
{
|
|
171
|
+
{
|
|
172
|
+
isAtChatRoot: vi.fn(() => true),
|
|
173
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
|
|
144
174
|
{} as ConstructorParameters<typeof ChatSessionListManager>[1]
|
|
145
175
|
);
|
|
146
176
|
|
|
147
|
-
manager.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
updatedAt: '2026-04-10T10:00:00.000Z'
|
|
151
|
-
}
|
|
152
|
-
]);
|
|
153
|
-
|
|
154
|
-
expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
|
|
177
|
+
manager.markSessionRead(
|
|
178
|
+
'session-2',
|
|
179
|
+
'2026-04-10T10:00:00.000Z',
|
|
155
180
|
'2026-04-10T10:00:00.000Z'
|
|
156
181
|
);
|
|
157
|
-
|
|
182
|
+
|
|
183
|
+
expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBeUndefined();
|
|
184
|
+
expect(mocks.updateNcpSession).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('promotes the active root draft to a concrete session route after the draft has been sent successfully', () => {
|
|
188
|
+
useChatSessionListStore.setState({
|
|
189
|
+
snapshot: {
|
|
190
|
+
...useChatSessionListStore.getState().snapshot,
|
|
191
|
+
selectedSessionKey: null,
|
|
192
|
+
draftSessionKey: 'draft-root-2',
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const uiManager = {
|
|
196
|
+
goToChatRoot: vi.fn(),
|
|
197
|
+
goToSession: vi.fn(),
|
|
198
|
+
isAtChatRoot: vi.fn(() => true),
|
|
199
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
200
|
+
const streamActionsManager = {
|
|
201
|
+
resetStreamState: vi.fn()
|
|
202
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
203
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
204
|
+
|
|
205
|
+
manager.ensureDraftSession('native');
|
|
206
|
+
manager.promoteRootDraftSessionRoute('draft-root-2');
|
|
207
|
+
|
|
208
|
+
expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2', { replace: true });
|
|
158
209
|
});
|
|
159
210
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
2
2
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
3
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
3
4
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
4
5
|
import type { SetStateAction } from 'react';
|
|
5
6
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
6
7
|
import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
|
|
7
8
|
import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
|
|
9
|
+
import { updateNcpSession } from '@/api/ncp-session';
|
|
8
10
|
|
|
9
11
|
export class ChatSessionListManager {
|
|
10
12
|
constructor(
|
|
@@ -12,6 +14,22 @@ export class ChatSessionListManager {
|
|
|
12
14
|
private streamActionsManager: ChatStreamActionsManager
|
|
13
15
|
) {}
|
|
14
16
|
|
|
17
|
+
private syncDraftThreadState = (sessionKey: string) => {
|
|
18
|
+
useChatThreadStore.getState().setSnapshot({
|
|
19
|
+
sessionKey,
|
|
20
|
+
sessionDisplayName: undefined,
|
|
21
|
+
canDeleteSession: false,
|
|
22
|
+
isHistoryLoading: false,
|
|
23
|
+
messages: [],
|
|
24
|
+
isSending: false,
|
|
25
|
+
isAwaitingAssistantOutput: false,
|
|
26
|
+
parentSessionKey: null,
|
|
27
|
+
parentSessionLabel: null,
|
|
28
|
+
childSessionTabs: [],
|
|
29
|
+
activeChildSessionKey: null,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
15
33
|
private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
|
|
16
34
|
if (typeof next === 'function') {
|
|
17
35
|
return (next as (value: T) => T)(prev);
|
|
@@ -19,6 +37,22 @@ export class ChatSessionListManager {
|
|
|
19
37
|
return next;
|
|
20
38
|
};
|
|
21
39
|
|
|
40
|
+
private shouldPersistReadAt = (
|
|
41
|
+
sessionKey: string,
|
|
42
|
+
readAt: string,
|
|
43
|
+
currentReadAt?: string | null,
|
|
44
|
+
): boolean => {
|
|
45
|
+
const optimisticReadAt = useChatSessionListStore.getState().optimisticReadAtBySessionKey[sessionKey];
|
|
46
|
+
const effectiveCurrentReadAt =
|
|
47
|
+
optimisticReadAt && currentReadAt
|
|
48
|
+
? (optimisticReadAt.localeCompare(currentReadAt) > 0 ? optimisticReadAt : currentReadAt)
|
|
49
|
+
: optimisticReadAt ?? currentReadAt ?? undefined;
|
|
50
|
+
if (!effectiveCurrentReadAt) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return readAt.localeCompare(effectiveCurrentReadAt) > 0;
|
|
54
|
+
};
|
|
55
|
+
|
|
22
56
|
setSelectedAgentId = (next: SetStateAction<string>) => {
|
|
23
57
|
const prev = useChatSessionListStore.getState().snapshot.selectedAgentId;
|
|
24
58
|
const value = this.resolveUpdateValue(prev, next);
|
|
@@ -46,22 +80,25 @@ export class ChatSessionListManager {
|
|
|
46
80
|
useChatSessionListStore.getState().setSnapshot({ listMode: value });
|
|
47
81
|
};
|
|
48
82
|
|
|
49
|
-
markSessionRead = (
|
|
50
|
-
|
|
83
|
+
markSessionRead = (
|
|
84
|
+
sessionKey: string | null | undefined,
|
|
85
|
+
readAt: string | null | undefined,
|
|
86
|
+
currentReadAt?: string | null,
|
|
87
|
+
) => {
|
|
88
|
+
const normalizedSessionKey = sessionKey?.trim();
|
|
89
|
+
const normalizedReadAt = readAt?.trim();
|
|
90
|
+
if (!normalizedSessionKey || !normalizedReadAt) {
|
|
51
91
|
return;
|
|
52
92
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
) => {
|
|
59
|
-
useChatSessionListStore.getState().hydrateReadWatermarks(entries);
|
|
93
|
+
if (!this.shouldPersistReadAt(normalizedSessionKey, normalizedReadAt, currentReadAt)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
useChatSessionListStore.getState().markSessionRead(normalizedSessionKey, normalizedReadAt);
|
|
97
|
+
void updateNcpSession(normalizedSessionKey, { uiReadAt: normalizedReadAt }).catch(() => undefined);
|
|
60
98
|
};
|
|
61
99
|
|
|
62
100
|
createSession = (sessionType?: string, projectRoot?: string | null): string => {
|
|
63
101
|
const { snapshot } = useChatInputStore.getState();
|
|
64
|
-
const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
|
|
65
102
|
const { defaultSessionType: configuredDefaultSessionType } = snapshot;
|
|
66
103
|
const defaultSessionType = configuredDefaultSessionType || 'native';
|
|
67
104
|
const nextSessionType =
|
|
@@ -69,17 +106,19 @@ export class ChatSessionListManager {
|
|
|
69
106
|
? sessionType.trim()
|
|
70
107
|
: defaultSessionType;
|
|
71
108
|
const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
|
|
72
|
-
const nextSessionKey =
|
|
109
|
+
const nextSessionKey = createNcpSessionId();
|
|
73
110
|
this.streamActionsManager.resetStreamState();
|
|
74
111
|
useChatSessionListStore.getState().setSnapshot({
|
|
75
|
-
|
|
112
|
+
selectedSessionKey: null,
|
|
113
|
+
draftSessionKey: nextSessionKey
|
|
76
114
|
});
|
|
115
|
+
this.syncDraftThreadState(nextSessionKey);
|
|
77
116
|
useChatInputStore.getState().setSnapshot({
|
|
78
117
|
pendingSessionType: nextSessionType,
|
|
79
118
|
pendingProjectRoot: normalizedProjectRoot,
|
|
80
119
|
pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
|
|
81
120
|
});
|
|
82
|
-
this.uiManager.
|
|
121
|
+
this.uiManager.goToChatRoot();
|
|
83
122
|
return nextSessionKey;
|
|
84
123
|
};
|
|
85
124
|
|
|
@@ -88,7 +127,33 @@ export class ChatSessionListManager {
|
|
|
88
127
|
if (snapshot.selectedSessionKey) {
|
|
89
128
|
return snapshot.selectedSessionKey;
|
|
90
129
|
}
|
|
91
|
-
|
|
130
|
+
const normalizedSessionType =
|
|
131
|
+
typeof sessionType === 'string' && sessionType.trim().length > 0
|
|
132
|
+
? sessionType.trim()
|
|
133
|
+
: null;
|
|
134
|
+
this.syncDraftThreadState(snapshot.draftSessionKey);
|
|
135
|
+
if (normalizedSessionType) {
|
|
136
|
+
useChatInputStore.getState().setSnapshot({ pendingSessionType: normalizedSessionType });
|
|
137
|
+
}
|
|
138
|
+
return snapshot.draftSessionKey;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
promoteRootDraftSessionRoute = (sessionKey: string) => {
|
|
142
|
+
const normalizedSessionKey = sessionKey.trim();
|
|
143
|
+
if (!normalizedSessionKey) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const { snapshot } = useChatSessionListStore.getState();
|
|
147
|
+
const { sessionKey: currentThreadSessionKey } = useChatThreadStore.getState().snapshot;
|
|
148
|
+
if (
|
|
149
|
+
snapshot.selectedSessionKey !== null ||
|
|
150
|
+
snapshot.draftSessionKey !== normalizedSessionKey ||
|
|
151
|
+
currentThreadSessionKey !== normalizedSessionKey ||
|
|
152
|
+
!this.uiManager.isAtChatRoot()
|
|
153
|
+
) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.uiManager.goToSession(normalizedSessionKey, { replace: true });
|
|
92
157
|
};
|
|
93
158
|
|
|
94
159
|
selectSession = (sessionKey: string) => {
|
|
@@ -59,6 +59,8 @@ export class ChatUiManager {
|
|
|
59
59
|
this.navigateTo('/chat', options);
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
isAtChatRoot = () => this.state.pathname === '/chat';
|
|
63
|
+
|
|
62
64
|
goToSession = (sessionKey: string, options?: NavigateOptions) => {
|
|
63
65
|
this.navigateTo(buildSessionPath(sessionKey), options);
|
|
64
66
|
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
## 子树边界豁免
|
|
2
2
|
|
|
3
|
-
- 原因:`chat/ncp/` 目录是 NCP 聊天运行时的装配子树,需要并列保留页面数据、派生状态、输入/线程 manager、session adapter 与测试文件。本次新增派生状态模块是为了拆短 `
|
|
3
|
+
- 原因:`chat/ncp/` 目录是 NCP 聊天运行时的装配子树,需要并列保留页面数据、派生状态、输入/线程 manager、session adapter 与测试文件。本次新增派生状态模块是为了拆短 `ncp-chat-page.tsx`,属于职责下沉,而不是继续把复杂度堆回页面壳。
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '@/components/chat/chat-composer-state';
|
|
16
16
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
17
17
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
18
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
18
19
|
import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
|
|
19
20
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
20
21
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
@@ -170,6 +171,7 @@ export class NcpChatInputManager {
|
|
|
170
171
|
send = async () => {
|
|
171
172
|
const inputSnapshot = useChatInputStore.getState().snapshot;
|
|
172
173
|
const sessionSnapshot = useChatSessionListStore.getState().snapshot;
|
|
174
|
+
const threadSnapshot = useChatThreadStore.getState().snapshot;
|
|
173
175
|
const message = inputSnapshot.draft.trim();
|
|
174
176
|
const attachments = inputSnapshot.attachments;
|
|
175
177
|
const parts = deriveNcpMessagePartsFromComposer(inputSnapshot.composerNodes, attachments);
|
|
@@ -180,7 +182,10 @@ export class NcpChatInputManager {
|
|
|
180
182
|
return;
|
|
181
183
|
}
|
|
182
184
|
const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
|
|
183
|
-
const sessionKey =
|
|
185
|
+
const sessionKey =
|
|
186
|
+
threadSnapshot.sessionKey ??
|
|
187
|
+
sessionSnapshot.selectedSessionKey ??
|
|
188
|
+
this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
|
|
184
189
|
this.setComposerNodes(createInitialChatComposerNodes());
|
|
185
190
|
await this.streamActionsManager.sendMessage({
|
|
186
191
|
message,
|
|
@@ -196,6 +201,7 @@ export class NcpChatInputManager {
|
|
|
196
201
|
restoreDraftOnError: true,
|
|
197
202
|
composerNodes
|
|
198
203
|
});
|
|
204
|
+
this.sessionListManager.promoteRootDraftSessionRoute(sessionKey);
|
|
199
205
|
};
|
|
200
206
|
|
|
201
207
|
stop = async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildNcpSendMetadata } from '@/components/chat/ncp/
|
|
2
|
+
import { buildNcpSendMetadata } from '@/components/chat/ncp/ncp-chat-page';
|
|
3
3
|
import { filterModelOptionsBySessionType } from '@/components/chat/ncp/ncp-chat-page-data';
|
|
4
4
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
5
|
|
|
@@ -21,12 +21,14 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
21
21
|
const adapted = adaptNcpSessionSummary(
|
|
22
22
|
createSummary({
|
|
23
23
|
agentId: 'engineer',
|
|
24
|
+
lastMessageAt: '2026-03-18T00:00:00.000Z',
|
|
24
25
|
metadata: {
|
|
25
26
|
label: 'NCP Planning Thread',
|
|
26
27
|
model: 'openai/gpt-5',
|
|
27
28
|
preferred_thinking: 'medium',
|
|
28
29
|
project_root: '/Users/demo/workspace/project-alpha',
|
|
29
|
-
session_type: 'native'
|
|
30
|
+
session_type: 'native',
|
|
31
|
+
ui_last_read_at: '2026-03-17T23:59:00.000Z'
|
|
30
32
|
}
|
|
31
33
|
})
|
|
32
34
|
);
|
|
@@ -39,6 +41,8 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
39
41
|
preferredThinking: 'medium',
|
|
40
42
|
projectRoot: '/Users/demo/workspace/project-alpha',
|
|
41
43
|
projectName: 'project-alpha',
|
|
44
|
+
lastMessageAt: '2026-03-18T00:00:00.000Z',
|
|
45
|
+
readAt: '2026-03-17T23:59:00.000Z',
|
|
42
46
|
sessionType: 'native',
|
|
43
47
|
sessionTypeMutable: false,
|
|
44
48
|
isChildSession: false,
|
|
@@ -80,6 +80,14 @@ function readNcpSessionProjectRoot(summary: NcpSessionSummaryView): string | nul
|
|
|
80
80
|
return normalizeSessionProjectRootValue(metadata.project_root ?? metadata.projectRoot);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function readNcpSessionReadAt(summary: NcpSessionSummaryView): string | null {
|
|
84
|
+
const metadata = readMetadata(summary);
|
|
85
|
+
if (!metadata) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return readOptionalString(metadata.ui_last_read_at);
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
84
92
|
const metadata = readMetadata(summary);
|
|
85
93
|
if (!metadata) {
|
|
@@ -249,6 +257,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
249
257
|
const preferredModel = readNcpSessionPreferredModel(summary);
|
|
250
258
|
const preferredThinking = readNcpSessionPreferredThinking(summary);
|
|
251
259
|
const projectRoot = readNcpSessionProjectRoot(summary);
|
|
260
|
+
const readAt = readNcpSessionReadAt(summary);
|
|
261
|
+
const lastMessageAt = readOptionalString(summary.lastMessageAt);
|
|
252
262
|
const projectName = getSessionProjectName(projectRoot);
|
|
253
263
|
const context = parseSessionContext(summary.sessionId);
|
|
254
264
|
const parentSessionId = readNcpParentSessionId(summary);
|
|
@@ -258,6 +268,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
258
268
|
key: summary.sessionId,
|
|
259
269
|
createdAt: summary.updatedAt,
|
|
260
270
|
updatedAt: summary.updatedAt,
|
|
271
|
+
...(lastMessageAt ? { lastMessageAt } : {}),
|
|
272
|
+
...(readAt ? { readAt } : {}),
|
|
261
273
|
...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
|
|
262
274
|
...(label ? { label } : {}),
|
|
263
275
|
...context,
|
|
@@ -13,6 +13,8 @@ export type ResolvedChildSessionTab = {
|
|
|
13
13
|
title: string;
|
|
14
14
|
agentId: string | null;
|
|
15
15
|
updatedAt: string | null;
|
|
16
|
+
lastMessageAt: string | null;
|
|
17
|
+
readAt: string | null;
|
|
16
18
|
runStatus?: SessionRunStatus;
|
|
17
19
|
sessionTypeLabel: string | null;
|
|
18
20
|
preferredModel: string | null;
|
|
@@ -64,6 +66,8 @@ export function useNcpChildSessionTabsView(
|
|
|
64
66
|
title: resolveChildSessionTitle(tab, session),
|
|
65
67
|
agentId,
|
|
66
68
|
updatedAt: session?.updatedAt ?? null,
|
|
69
|
+
lastMessageAt: session?.lastMessageAt ?? null,
|
|
70
|
+
readAt: session?.readAt ?? null,
|
|
67
71
|
runStatus: summary?.status === "running" ? "running" : undefined,
|
|
68
72
|
sessionTypeLabel: session?.sessionType
|
|
69
73
|
? resolveSessionTypeLabel(session.sessionType)
|