@nextclaw/ui 0.20.1 → 0.20.2
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 +18 -0
- package/dist/assets/{api-BBFMpRGl.js → api-DvpQWjTS.js} +3 -3
- package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-U0m0ktu3.js} +1 -1
- package/dist/assets/{book-open-BhTIpBKg.js → book-open-DRofSTWr.js} +1 -1
- package/dist/assets/channels-list-page-BURQarD0.js +8 -0
- package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BrAQYTIl.js} +1 -1
- package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-DDJomLnO.js} +1 -1
- package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DzkNHr5Z.js} +1 -1
- package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BzZFhMv9.js} +1 -1
- package/dist/assets/{dist-CXN62DSs.js → dist-DXbLo2b1.js} +1 -1
- package/dist/assets/{dist-DdE-kCbi.js → dist-NskPB_yW.js} +1 -1
- package/dist/assets/doc-browser-DB22tQKU.js +1 -0
- package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-CnKV4gJy.js} +1 -1
- package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
- package/dist/assets/{extensions-CMctxyTf.js → extensions-DvlFMpAs.js} +1 -1
- package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-UnPEZBkx.js} +1 -1
- package/dist/assets/index-Dc_ZMkmo.css +1 -0
- package/dist/assets/index-N1HnesBp.js +109 -0
- package/dist/assets/loader-circle-BlPXxgDB.js +1 -0
- package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +9 -0
- package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +1 -0
- package/dist/assets/model-config-page-CnSm7kfa.js +1 -0
- package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-NjUdG6Vp.js} +1 -1
- package/dist/assets/plus-DVXol9Q5.js +1 -0
- package/dist/assets/privacy-settings-page-DVn2HU7W.js +1 -0
- package/dist/assets/providers-config-page-CzHJS3eg.js +1 -0
- package/dist/assets/react-DCVbGroo.js +8 -0
- package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-CkeninE0.js} +1 -1
- package/dist/assets/remote-B2lsJF1c.js +1 -0
- package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-DP33PuXU.js} +1 -1
- package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
- package/dist/assets/{save-DWnYYpgQ.js → save-Cw2Xz-pB.js} +1 -1
- package/dist/assets/{search-D6pX7FC-.js → search-C2CFAVvd.js} +1 -1
- package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-dqNT0Swf.js} +1 -1
- package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-De6J3wVx.js} +2 -2
- package/dist/assets/security-config-Dcu9C4OL.js +1 -0
- package/dist/assets/{select-qEtFTWB5.js → select-DSuMhwVP.js} +1 -1
- package/dist/assets/skeleton-QoWydNLp.js +1 -0
- package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-DFBLgsGf.js} +1 -1
- package/dist/assets/x-BXvAzpjb.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +7 -7
- package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
- package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +11 -4
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +17 -5
- package/src/features/chat/components/layout/chat-sidebar.tsx +28 -21
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
- package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
- package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
- package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
- package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
- package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
- package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
- package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
- package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
- package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
- package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
- package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
- package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
- package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
- package/src/shared/components/ui/tooltip.tsx +16 -1
- package/src/shared/hooks/use-config.ts +11 -0
- package/src/shared/lib/api/types.ts +8 -0
- package/src/shared/lib/api/utils/config.utils.ts +5 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +7 -0
- package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -0
- package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
- package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
- package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
- package/dist/assets/doc-browser-c2yO477-.js +0 -1
- package/dist/assets/index-DUCA8m2w.js +0 -109
- package/dist/assets/index-QrdUWHfm.css +0 -1
- package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
- package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
- package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
- package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
- package/dist/assets/plus-DbeE4r-Q.js +0 -1
- package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
- package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
- package/dist/assets/react-DVMmnPWz.js +0 -8
- package/dist/assets/remote-DFoxj4ls.js +0 -1
- package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
- package/dist/assets/security-config-CUTUm6JT.js +0 -1
- package/dist/assets/skeleton-7MYPzn8w.js +0 -1
- package/dist/assets/x-DThaS5ZL.js +0 -1
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
useCreateProject,
|
|
22
22
|
useProjects,
|
|
23
23
|
} from "@/shared/hooks/use-projects";
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
ProjectAddExistingRequest,
|
|
26
|
+
ProjectCreateRequest,
|
|
27
|
+
} from "@/shared/lib/api";
|
|
25
28
|
import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
|
|
26
29
|
import { cn } from "@/shared/lib/utils";
|
|
27
30
|
import { LANGUAGE_OPTIONS, t, type I18nLanguage } from "@/shared/lib/i18n";
|
|
@@ -50,6 +53,7 @@ import { useChatNewSessionTypePreference } from "@/features/chat/features/sessio
|
|
|
50
53
|
import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
|
|
51
54
|
import { SIDEBAR_RAIL_WIDTH_CLASS } from "@/app/components/layout/sidebar-rail.styles";
|
|
52
55
|
import { ChatProjectAddDialog } from "@/features/chat/features/project/components/chat-project-add-dialog";
|
|
56
|
+
import { useChatSidebarContextCounts } from "@/features/chat/features/session/hooks/use-chat-sidebar-context-counts";
|
|
53
57
|
|
|
54
58
|
type ChatSidebarVariant = "desktop" | "mobile";
|
|
55
59
|
|
|
@@ -116,7 +120,9 @@ export function ChatSidebar({
|
|
|
116
120
|
const projectsQuery = useProjects();
|
|
117
121
|
const projectCreateMutation = useCreateProject();
|
|
118
122
|
const projectAddExistingMutation = useAddExistingProject();
|
|
119
|
-
const { isLoading, items } = useNcpSessionListView();
|
|
123
|
+
const { allItems, isLoading, items } = useNcpSessionListView();
|
|
124
|
+
const { cronJobCountByProjectRoot, cronJobCountBySessionKey } =
|
|
125
|
+
useChatSidebarContextCounts(allItems);
|
|
120
126
|
const { language, setLanguage } = useI18n();
|
|
121
127
|
const { theme, setTheme } = useTheme();
|
|
122
128
|
const currentThemeLabel = t(
|
|
@@ -160,21 +166,27 @@ export function ChatSidebar({
|
|
|
160
166
|
[items],
|
|
161
167
|
);
|
|
162
168
|
const childSessionsByParentKey = useMemo(
|
|
163
|
-
() => groupChildSessionsByParentKey(
|
|
164
|
-
[
|
|
169
|
+
() => groupChildSessionsByParentKey(allItems),
|
|
170
|
+
[allItems],
|
|
165
171
|
);
|
|
166
172
|
const groups = useMemo(
|
|
167
173
|
() => groupSessionsByDate(sortedItems, pinnedSessionKeys),
|
|
168
174
|
[pinnedSessionKeys, sortedItems],
|
|
169
175
|
);
|
|
170
176
|
const projectGroups = useMemo(
|
|
171
|
-
() =>
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
() =>
|
|
178
|
+
groupSessionsByProject(
|
|
179
|
+
sortedItems,
|
|
180
|
+
pinnedSessionKeys,
|
|
181
|
+
pinnedProjectRoots,
|
|
182
|
+
projectsQuery.data?.projects ?? [],
|
|
183
|
+
),
|
|
184
|
+
[
|
|
174
185
|
pinnedProjectRoots,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
pinnedSessionKeys,
|
|
187
|
+
projectsQuery.data?.projects,
|
|
188
|
+
sortedItems,
|
|
189
|
+
],
|
|
178
190
|
);
|
|
179
191
|
const sessionTypeOptions = useMemo(
|
|
180
192
|
() => buildSessionTypeOptions(sessionTypesData?.options ?? []),
|
|
@@ -220,6 +232,7 @@ export function ChatSidebar({
|
|
|
220
232
|
optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
|
|
221
233
|
agentsById={agentsById}
|
|
222
234
|
childSessionsByParentKey={childSessionsByParentKey}
|
|
235
|
+
cronJobCount={cronJobCountBySessionKey.get(item.session.key) ?? 0}
|
|
223
236
|
editingSessionKey={editingSessionKey}
|
|
224
237
|
draftLabel={draftLabel}
|
|
225
238
|
savingSessionKey={savingSessionKey}
|
|
@@ -227,12 +240,6 @@ export function ChatSidebar({
|
|
|
227
240
|
isPinned={pinnedSessionKeys.has(item.session.key)}
|
|
228
241
|
sessionTitle={getSessionTitle}
|
|
229
242
|
onSelectSession={presenter.chatSessionListManager.selectSession}
|
|
230
|
-
onOpenChildSessions={(parentSessionKey, activeChildSessionKey) =>
|
|
231
|
-
presenter.chatThreadManager.openChildSessionPanel({
|
|
232
|
-
parentSessionKey,
|
|
233
|
-
activeChildSessionKey,
|
|
234
|
-
})
|
|
235
|
-
}
|
|
236
243
|
onStartEditingSessionLabel={startEditingSessionLabel}
|
|
237
244
|
onDraftLabelChange={setDraftLabel}
|
|
238
245
|
onSaveSessionLabel={saveSessionLabel}
|
|
@@ -256,7 +263,9 @@ export function ChatSidebar({
|
|
|
256
263
|
projectAddExistingMutation.reset();
|
|
257
264
|
setIsProjectAddOpen(true);
|
|
258
265
|
};
|
|
259
|
-
const createProjectFromSidebar = async (
|
|
266
|
+
const createProjectFromSidebar = async (
|
|
267
|
+
input: ProjectCreateRequest,
|
|
268
|
+
): Promise<void> => {
|
|
260
269
|
await projectCreateMutation.mutateAsync(input);
|
|
261
270
|
setIsProjectAddOpen(false);
|
|
262
271
|
};
|
|
@@ -274,10 +283,7 @@ export function ChatSidebar({
|
|
|
274
283
|
isMobileVariant
|
|
275
284
|
? "flex-1 overflow-hidden"
|
|
276
285
|
: shouldCollapse
|
|
277
|
-
? cn(
|
|
278
|
-
SIDEBAR_RAIL_WIDTH_CLASS,
|
|
279
|
-
"shrink-0",
|
|
280
|
-
)
|
|
286
|
+
? cn(SIDEBAR_RAIL_WIDTH_CLASS, "shrink-0")
|
|
281
287
|
: "w-[280px] shrink-0",
|
|
282
288
|
)}
|
|
283
289
|
data-sidebar-collapsed={shouldCollapse ? "true" : "false"}
|
|
@@ -339,6 +345,7 @@ export function ChatSidebar({
|
|
|
339
345
|
onAddProject={openProjectAdd}
|
|
340
346
|
onSelectMode={presenter.chatSessionListManager.setListMode}
|
|
341
347
|
projectGroups={projectGroups}
|
|
348
|
+
projectCronJobCountByRoot={cronJobCountByProjectRoot}
|
|
342
349
|
renderSessionItem={renderSessionItem}
|
|
343
350
|
sessionTypeOptions={sessionTypeOptions}
|
|
344
351
|
/>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import type
|
|
3
|
+
import { NextClawClientError, type UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
|
|
4
4
|
import type { NcpAgentSendEnvelope, NcpMessage, NcpRunHandle } from '@nextclaw/ncp';
|
|
5
5
|
|
|
6
6
|
import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
|
|
@@ -448,3 +448,43 @@ describe('useSessionConversationController backend run queue', () => {
|
|
|
448
448
|
expect(params.onSessionMaterialized).not.toHaveBeenCalled();
|
|
449
449
|
});
|
|
450
450
|
});
|
|
451
|
+
|
|
452
|
+
describe('useSessionConversationController stale queue reconciliation', () => {
|
|
453
|
+
it('reconciles repeated stale queued actions without exposing their not-found errors', async () => {
|
|
454
|
+
const queuedInput = createQueuedInput();
|
|
455
|
+
const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
|
|
456
|
+
params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
|
|
457
|
+
code: 'NOT_FOUND',
|
|
458
|
+
message: `queued input not found in session session-1: ${queuedInput.id}`,
|
|
459
|
+
status: 404,
|
|
460
|
+
}));
|
|
461
|
+
const { result } = renderHook(() => useSessionConversationController(params));
|
|
462
|
+
|
|
463
|
+
act(() => result.current.steerQueuedInput(queuedInput.id));
|
|
464
|
+
await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(1));
|
|
465
|
+
|
|
466
|
+
act(() => result.current.steerQueuedInput(queuedInput.id));
|
|
467
|
+
await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(2));
|
|
468
|
+
|
|
469
|
+
expect(params.setSendError).toHaveBeenLastCalledWith(null);
|
|
470
|
+
expect(params.setSendError).not.toHaveBeenCalledWith(expect.stringContaining('queued input not found'));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('keeps a real steering failure visible', async () => {
|
|
474
|
+
const queuedInput = createQueuedInput();
|
|
475
|
+
const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
|
|
476
|
+
params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
|
|
477
|
+
code: 'STEER_UNAVAILABLE',
|
|
478
|
+
message: 'The active runtime cannot accept this input at the next safe step.',
|
|
479
|
+
status: 409,
|
|
480
|
+
}));
|
|
481
|
+
const { result } = renderHook(() => useSessionConversationController(params));
|
|
482
|
+
|
|
483
|
+
act(() => result.current.steerQueuedInput(queuedInput.id));
|
|
484
|
+
|
|
485
|
+
await waitFor(() => expect(params.setSendError).toHaveBeenCalledWith(
|
|
486
|
+
'The active runtime cannot accept this input at the next safe step.',
|
|
487
|
+
));
|
|
488
|
+
expect(params.runQueue.refreshPendingInputs).not.toHaveBeenCalled();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
NextClawClientError,
|
|
4
|
+
type UiNcpSessionPendingInputView,
|
|
5
|
+
type UiNcpSessionQueuedInputView,
|
|
5
6
|
} from '@nextclaw/client-sdk';
|
|
6
7
|
import type { NcpAgentSendEnvelope } from '@nextclaw/ncp';
|
|
7
8
|
|
|
@@ -20,10 +21,17 @@ type ComposerDraftSnapshot = Pick<
|
|
|
20
21
|
|
|
21
22
|
type SessionRunQueue = {
|
|
22
23
|
readonly inputs: readonly UiNcpSessionQueuedInputView[];
|
|
24
|
+
readonly refreshPendingInputs: () => Promise<readonly UiNcpSessionPendingInputView[]>;
|
|
23
25
|
readonly removeQueuedInput: (id: string) => Promise<UiNcpSessionQueuedInputView | null>;
|
|
24
26
|
readonly steerQueuedInput: (id: string) => Promise<UiNcpSessionPendingInputView | null>;
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
function isStaleQueuedInputError(error: unknown): boolean {
|
|
30
|
+
return error instanceof NextClawClientError
|
|
31
|
+
&& error.status === 404
|
|
32
|
+
&& error.code === 'NOT_FOUND';
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export type SubmittingQueuedInput = {
|
|
28
36
|
readonly attachments: readonly SessionQueuedInputAttachmentPreview[];
|
|
29
37
|
readonly id: string;
|
|
@@ -127,7 +135,17 @@ export function useQueuedInputActions(params: {
|
|
|
127
135
|
const steerQueuedInput = useCallback((id: string) => {
|
|
128
136
|
void runQueue.steerQueuedInput(id).then((steered) => {
|
|
129
137
|
if (steered) setSendError(null);
|
|
130
|
-
}).catch((error) => {
|
|
138
|
+
}).catch(async (error) => {
|
|
139
|
+
if (isStaleQueuedInputError(error)) {
|
|
140
|
+
try {
|
|
141
|
+
await runQueue.refreshPendingInputs();
|
|
142
|
+
setSendError(null);
|
|
143
|
+
return;
|
|
144
|
+
} catch (refreshError) {
|
|
145
|
+
setSendError(refreshError instanceof Error ? refreshError.message : String(refreshError));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
131
149
|
setSendError(error instanceof Error ? error.message : String(error));
|
|
132
150
|
});
|
|
133
151
|
}, [runQueue, setSendError]);
|
|
@@ -109,4 +109,39 @@ describe("buildChatMessageTriggerDetails", () => {
|
|
|
109
109
|
"run-trigger-metadata",
|
|
110
110
|
]);
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
it("keeps a completed steering user message actionable from persisted run metadata", () => {
|
|
114
|
+
const details = buildChatMessageTriggerDetails({
|
|
115
|
+
labels,
|
|
116
|
+
message: {
|
|
117
|
+
metadata: {
|
|
118
|
+
run_spec: {
|
|
119
|
+
runId: "run-1",
|
|
120
|
+
model: "openai/gpt-5.6",
|
|
121
|
+
},
|
|
122
|
+
run_trigger: {
|
|
123
|
+
version: 1,
|
|
124
|
+
actor: "human",
|
|
125
|
+
source: "internal",
|
|
126
|
+
sourceMessageId: "user-steering",
|
|
127
|
+
targetRunId: "run-1",
|
|
128
|
+
triggeredAt: "2026-08-25T13:39:04.000Z",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(details).toMatchObject({
|
|
135
|
+
triggerLabel: "More actions",
|
|
136
|
+
items: [{
|
|
137
|
+
key: "run-trigger-metadata",
|
|
138
|
+
dialog: {
|
|
139
|
+
rows: expect.arrayContaining([
|
|
140
|
+
{ label: "Target run", value: "run-1" },
|
|
141
|
+
{ label: "Run model", value: "openai/gpt-5.6" },
|
|
142
|
+
]),
|
|
143
|
+
},
|
|
144
|
+
}],
|
|
145
|
+
});
|
|
146
|
+
});
|
|
112
147
|
});
|
|
@@ -73,4 +73,54 @@ describe('useNcpAgentRuntime backend queue submission', () => {
|
|
|
73
73
|
expect(result.current.visibleMessages).toEqual([queuedMessage]);
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it('keeps a steered message in the pending projection instead of accepting it as a new run', async () => {
|
|
78
|
+
const manager = new DefaultNcpAgentConversationStateManager();
|
|
79
|
+
manager.hydrate({ sessionId: 'session-1', messages: [] });
|
|
80
|
+
await manager.dispatch({
|
|
81
|
+
type: NcpEventType.RunStarted,
|
|
82
|
+
payload: {
|
|
83
|
+
sessionId: 'session-1',
|
|
84
|
+
runId: 'run-1',
|
|
85
|
+
messageId: 'assistant-1',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const send = vi.fn(async (envelope: NcpAgentSendEnvelope) => ({
|
|
89
|
+
assistantMessageId: null,
|
|
90
|
+
delivery: 'steered' as const,
|
|
91
|
+
runId: 'run-1',
|
|
92
|
+
sessionId: 'session-1',
|
|
93
|
+
userMessageId: envelope.message.id,
|
|
94
|
+
}));
|
|
95
|
+
const client = {
|
|
96
|
+
abort: vi.fn(async () => undefined),
|
|
97
|
+
send,
|
|
98
|
+
stop: vi.fn(async () => undefined),
|
|
99
|
+
stream: vi.fn(async () => undefined),
|
|
100
|
+
subscribe: vi.fn(() => () => undefined),
|
|
101
|
+
} as unknown as NcpAgentClientEndpoint;
|
|
102
|
+
const { result } = renderHook(() => useNcpAgentRuntime({
|
|
103
|
+
client,
|
|
104
|
+
manager,
|
|
105
|
+
sessionId: 'session-1',
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await result.current.send({
|
|
110
|
+
delivery: 'prefer-steer',
|
|
111
|
+
message: {
|
|
112
|
+
id: 'user-steering',
|
|
113
|
+
sessionId: 'session-1',
|
|
114
|
+
role: 'user',
|
|
115
|
+
status: 'final',
|
|
116
|
+
parts: [{ type: 'text', text: 'change direction' }],
|
|
117
|
+
timestamp: '2026-08-25T00:00:00.000Z',
|
|
118
|
+
},
|
|
119
|
+
sessionId: 'session-1',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.current.visibleMessages).toEqual([]);
|
|
124
|
+
expect(result.current.snapshot.activeRun?.runId).toBe('run-1');
|
|
125
|
+
});
|
|
76
126
|
});
|
|
@@ -62,13 +62,34 @@ describe("useNcpSessionListView", () => {
|
|
|
62
62
|
it("can override the hidden sidebar query for header switching", () => {
|
|
63
63
|
useChatSessionListStore.getState().setSnapshot({ query: "Alpha" });
|
|
64
64
|
|
|
65
|
-
const { result } = renderHook(() =>
|
|
66
|
-
useNcpSessionListView({ query: "" }),
|
|
67
|
-
);
|
|
65
|
+
const { result } = renderHook(() => useNcpSessionListView({ query: "" }));
|
|
68
66
|
|
|
69
67
|
expect(result.current.items.map((item) => item.session.key)).toEqual([
|
|
70
68
|
"session:alpha",
|
|
71
69
|
"session:beta",
|
|
72
70
|
]);
|
|
73
71
|
});
|
|
72
|
+
|
|
73
|
+
it("keeps hidden child sessions available for sidebar context counts", () => {
|
|
74
|
+
mocks.sessions.push({
|
|
75
|
+
...createSummary("session:alpha:child", "Child Task"),
|
|
76
|
+
metadata: {
|
|
77
|
+
label: "Child Task",
|
|
78
|
+
session_type: "native",
|
|
79
|
+
parent_session_id: "session:alpha",
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { result } = renderHook(() => useNcpSessionListView());
|
|
84
|
+
|
|
85
|
+
expect(result.current.items.map((item) => item.session.key)).toEqual([
|
|
86
|
+
"session:alpha",
|
|
87
|
+
"session:beta",
|
|
88
|
+
]);
|
|
89
|
+
expect(result.current.allItems.map((item) => item.session.key)).toEqual([
|
|
90
|
+
"session:alpha",
|
|
91
|
+
"session:beta",
|
|
92
|
+
"session:alpha:child",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
74
95
|
});
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { useMemo } from
|
|
2
|
-
import type { SessionEntryView } from
|
|
3
|
-
import { sessionMatchesQuery } from
|
|
4
|
-
import { adaptNcpSessionSummaries } from
|
|
5
|
-
import { useChatSessionListStore } from
|
|
6
|
-
import { useNcpSessions } from
|
|
7
|
-
import type { SessionRunStatus } from
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { SessionEntryView } from "@/shared/lib/api";
|
|
3
|
+
import { sessionMatchesQuery } from "@/features/chat/features/session/utils/chat-session-display.utils";
|
|
4
|
+
import { adaptNcpSessionSummaries } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
|
|
5
|
+
import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
|
|
6
|
+
import { useNcpSessions } from "@/features/chat/features/ncp/hooks/use-ncp-session-queries";
|
|
7
|
+
import type { SessionRunStatus } from "@/features/chat/types/session-run-status.types";
|
|
8
8
|
|
|
9
9
|
export type NcpSessionListItemView = {
|
|
10
10
|
session: SessionEntryView;
|
|
11
11
|
runStatus?: SessionRunStatus;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
function filterSessionsByQuery(
|
|
14
|
+
function filterSessionsByQuery(
|
|
15
|
+
sessions: readonly SessionEntryView[],
|
|
16
|
+
query: string,
|
|
17
|
+
): SessionEntryView[] {
|
|
15
18
|
return sessions.filter((session) => sessionMatchesQuery(session, query));
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,26 +25,39 @@ function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
|
|
|
22
25
|
return session.isPromotedChildSession === true;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
export function useNcpSessionListView(
|
|
28
|
+
export function useNcpSessionListView(
|
|
29
|
+
params: { limit?: number; query?: string | null } = {},
|
|
30
|
+
) {
|
|
26
31
|
const storedQuery = useChatSessionListStore((state) => state.snapshot.query);
|
|
27
32
|
const query = params.query ?? storedQuery;
|
|
28
33
|
const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
|
|
29
34
|
|
|
30
|
-
const
|
|
35
|
+
const allItems = useMemo<NcpSessionListItemView[]>(() => {
|
|
31
36
|
const summaries = sessionsQuery.data?.sessions ?? [];
|
|
32
|
-
|
|
33
|
-
shouldShowSessionInSidebar,
|
|
34
|
-
);
|
|
35
|
-
const filteredSessions = filterSessionsByQuery(sessions, query);
|
|
36
|
-
|
|
37
|
-
return filteredSessions.map((session) => ({
|
|
37
|
+
return adaptNcpSessionSummaries(summaries).map((session) => ({
|
|
38
38
|
session,
|
|
39
|
-
runStatus: session.status ===
|
|
39
|
+
runStatus: session.status === "running" ? "running" : undefined,
|
|
40
40
|
}));
|
|
41
|
-
}, [
|
|
41
|
+
}, [sessionsQuery.data?.sessions]);
|
|
42
|
+
const items = useMemo<NcpSessionListItemView[]>(() => {
|
|
43
|
+
const visibleItems = allItems.filter(({ session }) =>
|
|
44
|
+
shouldShowSessionInSidebar(session),
|
|
45
|
+
);
|
|
46
|
+
const matchingSessionKeys = new Set(
|
|
47
|
+
filterSessionsByQuery(
|
|
48
|
+
visibleItems.map(({ session }) => session),
|
|
49
|
+
query,
|
|
50
|
+
).map((session) => session.key),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return visibleItems.filter(({ session }) =>
|
|
54
|
+
matchingSessionKeys.has(session.key),
|
|
55
|
+
);
|
|
56
|
+
}, [allItems, query]);
|
|
42
57
|
|
|
43
58
|
return {
|
|
59
|
+
allItems,
|
|
44
60
|
isLoading: sessionsQuery.isLoading,
|
|
45
|
-
items
|
|
61
|
+
items,
|
|
46
62
|
};
|
|
47
63
|
}
|
package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
2
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { ChatSidebarProjectGroups } from "@/features/chat/features/session/components/chat-sidebar-project-groups";
|
|
4
5
|
import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
|
|
@@ -38,6 +39,7 @@ function renderProjectGroups(isPinned = false) {
|
|
|
38
39
|
return render(
|
|
39
40
|
<ChatSidebarProjectGroups
|
|
40
41
|
groups={[{ ...projectGroup, isPinned }]}
|
|
42
|
+
projectCronJobCountByRoot={new Map([[projectGroup.projectRoot, 2]])}
|
|
41
43
|
defaultSessionType="native"
|
|
42
44
|
sessionTypeOptions={[
|
|
43
45
|
{ value: "native", label: "Native", icon: null, ready: true },
|
|
@@ -74,7 +76,15 @@ describe("ChatSidebarProjectGroups", () => {
|
|
|
74
76
|
expect(header.parentElement?.className).toContain("h-8");
|
|
75
77
|
expect(header.parentElement?.className).toContain("hover:bg-gray-200/60");
|
|
76
78
|
expect(header.parentElement?.className).not.toContain("focus-within:bg");
|
|
77
|
-
expect(
|
|
79
|
+
expect(
|
|
80
|
+
screen.getByText("analysis-project").nextElementSibling?.tagName,
|
|
81
|
+
).toBe("svg");
|
|
82
|
+
expect(
|
|
83
|
+
screen
|
|
84
|
+
.getByText("analysis-project")
|
|
85
|
+
.nextElementSibling?.getAttribute("class"),
|
|
86
|
+
).toContain("opacity-0");
|
|
87
|
+
expect(screen.queryByText("Sessions")).toBeNull();
|
|
78
88
|
|
|
79
89
|
fireEvent.click(header);
|
|
80
90
|
|
|
@@ -87,6 +97,20 @@ describe("ChatSidebarProjectGroups", () => {
|
|
|
87
97
|
expect(screen.queryByText("Project session")).toBeNull();
|
|
88
98
|
});
|
|
89
99
|
|
|
100
|
+
it("shows project context from the row on hover", async () => {
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
renderProjectGroups();
|
|
103
|
+
|
|
104
|
+
await user.hover(screen.getByLabelText("Collapse project"));
|
|
105
|
+
|
|
106
|
+
const tooltip = await screen.findByRole("tooltip");
|
|
107
|
+
expect(within(tooltip).getByText("Path")).toBeTruthy();
|
|
108
|
+
expect(within(tooltip).getByText("Sessions")).toBeTruthy();
|
|
109
|
+
expect(within(tooltip).getByText("Scheduled tasks")).toBeTruthy();
|
|
110
|
+
expect(within(tooltip).getByText("/tmp/analysis-project")).toBeTruthy();
|
|
111
|
+
expect(within(tooltip).getByText("2")).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
|
|
90
114
|
it("keeps project creation and pinning in one trailing action cluster", () => {
|
|
91
115
|
renderProjectGroups();
|
|
92
116
|
|
package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { render, screen } from
|
|
2
|
-
import userEvent from
|
|
3
|
-
import { beforeEach, expect, it, vi } from
|
|
4
|
-
import { ChatSidebarSessionItem } from
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { ChatSidebarSessionItem } from "@/features/chat/features/session/components/chat-sidebar-session-item";
|
|
5
5
|
|
|
6
6
|
const mocks = vi.hoisted(() => ({
|
|
7
7
|
copyText: vi.fn(),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
vi.mock(
|
|
10
|
+
vi.mock("@nextclaw/agent-chat-ui", () => ({
|
|
11
11
|
copyText: mocks.copyText,
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
-
vi.mock(
|
|
14
|
+
vi.mock("sonner", () => ({
|
|
15
15
|
toast: {
|
|
16
16
|
success: vi.fn(),
|
|
17
17
|
error: vi.fn(),
|
|
@@ -28,9 +28,9 @@ beforeEach(() => {
|
|
|
28
28
|
showUnreadDot={false}
|
|
29
29
|
context={{
|
|
30
30
|
icon: {
|
|
31
|
-
kind:
|
|
32
|
-
src:
|
|
33
|
-
name:
|
|
31
|
+
kind: "runtime-image",
|
|
32
|
+
src: "/runtime-icons/codex-openai.svg",
|
|
33
|
+
name: "Codex",
|
|
34
34
|
},
|
|
35
35
|
label: null,
|
|
36
36
|
}}
|
|
@@ -38,6 +38,9 @@ beforeEach(() => {
|
|
|
38
38
|
title="Current Task"
|
|
39
39
|
previewText="Preview"
|
|
40
40
|
trailingText="Now"
|
|
41
|
+
childSessionCount={2}
|
|
42
|
+
cronJobCount={3}
|
|
43
|
+
projectName="nextbot"
|
|
41
44
|
isEditing={false}
|
|
42
45
|
draftLabel="Current Task"
|
|
43
46
|
isSaving={false}
|
|
@@ -51,33 +54,58 @@ beforeEach(() => {
|
|
|
51
54
|
);
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
it(
|
|
55
|
-
screen.getByText(
|
|
56
|
-
const actions = screen.getByLabelText(
|
|
57
|
+
it("shows session actions only on hover or when an action owns focus", () => {
|
|
58
|
+
screen.getByText("Current Task").closest("button")?.focus();
|
|
59
|
+
const actions = screen.getByLabelText("Pin session").parentElement;
|
|
60
|
+
const titleRow =
|
|
61
|
+
screen.getByText("Current Task").parentElement?.parentElement;
|
|
57
62
|
|
|
58
|
-
expect(actions?.className).toContain(
|
|
59
|
-
expect(actions?.className).toContain(
|
|
60
|
-
expect(actions?.className).toContain(
|
|
63
|
+
expect(actions?.className).toContain("opacity-0");
|
|
64
|
+
expect(actions?.className).toContain("group-hover/session:opacity-100");
|
|
65
|
+
expect(actions?.className).toContain("focus-within:opacity-100");
|
|
61
66
|
expect(actions?.className).not.toContain(
|
|
62
|
-
|
|
67
|
+
"group-focus-within/session:opacity-100",
|
|
63
68
|
);
|
|
64
|
-
expect(
|
|
69
|
+
expect(titleRow?.classList.contains("pr-20")).toBe(false);
|
|
70
|
+
expect(titleRow?.className).toContain("group-hover/session:pr-20");
|
|
71
|
+
expect(titleRow?.className).toContain(
|
|
72
|
+
"group-has-[[data-session-actions]:focus-within]/session:pr-20",
|
|
73
|
+
);
|
|
74
|
+
expect(titleRow?.className).not.toContain("pr-32");
|
|
75
|
+
expect(
|
|
76
|
+
screen.queryByRole("button", { name: "View child sessions" }),
|
|
77
|
+
).toBeNull();
|
|
78
|
+
expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy();
|
|
65
79
|
});
|
|
66
80
|
|
|
67
|
-
it(
|
|
81
|
+
it("copies the sidebar session ID from the more-actions menu", async () => {
|
|
68
82
|
const user = userEvent.setup();
|
|
69
83
|
|
|
70
|
-
await user.click(screen.getByRole(
|
|
71
|
-
await user.click(screen.getByRole(
|
|
84
|
+
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
85
|
+
await user.click(screen.getByRole("button", { name: "Copy session ID" }));
|
|
86
|
+
|
|
87
|
+
expect(mocks.copyText).toHaveBeenCalledWith("session:current");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("sizes the runtime icon to the session title text", () => {
|
|
91
|
+
const runtimeIcon = screen.getByRole("img", { name: "Codex logo" });
|
|
72
92
|
|
|
73
|
-
expect(
|
|
93
|
+
expect(runtimeIcon.parentElement?.className).toContain("h-[13px]");
|
|
94
|
+
expect(runtimeIcon.parentElement?.className).toContain("w-[13px]");
|
|
95
|
+
expect(runtimeIcon.parentElement?.parentElement?.className).toContain("h-4");
|
|
96
|
+
expect(runtimeIcon.parentElement?.parentElement?.className).toContain("w-4");
|
|
74
97
|
});
|
|
75
98
|
|
|
76
|
-
it(
|
|
77
|
-
const
|
|
99
|
+
it("shows session context from the row on hover", async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
|
|
102
|
+
await user.hover(screen.getByText("Current Task").closest("button")!);
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
expect(
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
104
|
+
const tooltip = await screen.findByRole("tooltip");
|
|
105
|
+
expect(within(tooltip).getByText("Project")).toBeTruthy();
|
|
106
|
+
expect(within(tooltip).getByText("Child sessions")).toBeTruthy();
|
|
107
|
+
expect(within(tooltip).getByText("Scheduled tasks")).toBeTruthy();
|
|
108
|
+
expect(within(tooltip).getByText("nextbot")).toBeTruthy();
|
|
109
|
+
expect(within(tooltip).getByText("2")).toBeTruthy();
|
|
110
|
+
expect(within(tooltip).getByText("3")).toBeTruthy();
|
|
83
111
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Tooltip,
|
|
4
|
+
TooltipContent,
|
|
5
|
+
TooltipProvider,
|
|
6
|
+
TooltipTrigger,
|
|
7
|
+
} from "@/shared/components/ui/tooltip";
|
|
8
|
+
|
|
9
|
+
export type ChatSidebarContextMetric = {
|
|
10
|
+
icon: ReactNode;
|
|
11
|
+
label: string;
|
|
12
|
+
value: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function ChatSidebarContextCard({
|
|
16
|
+
children,
|
|
17
|
+
metrics,
|
|
18
|
+
title,
|
|
19
|
+
}: {
|
|
20
|
+
children: ReactElement;
|
|
21
|
+
metrics: readonly ChatSidebarContextMetric[];
|
|
22
|
+
title: string;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<TooltipProvider delayDuration={350} skipDelayDuration={100}>
|
|
26
|
+
<Tooltip>
|
|
27
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
28
|
+
<TooltipContent
|
|
29
|
+
side="right"
|
|
30
|
+
align="start"
|
|
31
|
+
sideOffset={10}
|
|
32
|
+
collisionPadding={12}
|
|
33
|
+
className="pointer-events-none w-64 rounded-xl border-border/80 bg-popover p-3 text-popover-foreground shadow-[0_18px_48px_-24px_rgba(15,23,42,0.45)]"
|
|
34
|
+
>
|
|
35
|
+
<div className="truncate text-sm font-semibold leading-5">
|
|
36
|
+
{title}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="mt-2 space-y-0.5">
|
|
39
|
+
{metrics.map((metric) => (
|
|
40
|
+
<div
|
|
41
|
+
key={metric.label}
|
|
42
|
+
className="flex h-5 min-w-0 items-center gap-2 text-xs leading-5"
|
|
43
|
+
>
|
|
44
|
+
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
45
|
+
{metric.icon}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
48
|
+
{metric.label}
|
|
49
|
+
</span>
|
|
50
|
+
<span className="min-w-0 max-w-40 truncate tabular-nums text-foreground/80">
|
|
51
|
+
{metric.value}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</TooltipContent>
|
|
57
|
+
</Tooltip>
|
|
58
|
+
</TooltipProvider>
|
|
59
|
+
);
|
|
60
|
+
}
|