@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-DvpQWjTS.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-U0m0ktu3.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.js → book-open-DRofSTWr.js} +1 -1
  5. package/dist/assets/channels-list-page-BURQarD0.js +8 -0
  6. package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BrAQYTIl.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-DDJomLnO.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DzkNHr5Z.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BzZFhMv9.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-DXbLo2b1.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-NskPB_yW.js} +1 -1
  12. package/dist/assets/doc-browser-DB22tQKU.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-CnKV4gJy.js} +1 -1
  14. package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-DvlFMpAs.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-UnPEZBkx.js} +1 -1
  17. package/dist/assets/index-Dc_ZMkmo.css +1 -0
  18. package/dist/assets/index-N1HnesBp.js +109 -0
  19. package/dist/assets/loader-circle-BlPXxgDB.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +9 -0
  21. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +1 -0
  22. package/dist/assets/model-config-page-CnSm7kfa.js +1 -0
  23. package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-NjUdG6Vp.js} +1 -1
  24. package/dist/assets/plus-DVXol9Q5.js +1 -0
  25. package/dist/assets/privacy-settings-page-DVn2HU7W.js +1 -0
  26. package/dist/assets/providers-config-page-CzHJS3eg.js +1 -0
  27. package/dist/assets/react-DCVbGroo.js +8 -0
  28. package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-CkeninE0.js} +1 -1
  29. package/dist/assets/remote-B2lsJF1c.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-DP33PuXU.js} +1 -1
  31. package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-Cw2Xz-pB.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-C2CFAVvd.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-dqNT0Swf.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-De6J3wVx.js} +2 -2
  36. package/dist/assets/security-config-Dcu9C4OL.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-DSuMhwVP.js} +1 -1
  38. package/dist/assets/skeleton-QoWydNLp.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-DFBLgsGf.js} +1 -1
  40. package/dist/assets/x-BXvAzpjb.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +7 -7
  43. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
  44. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +11 -4
  45. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  46. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +17 -5
  47. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -21
  48. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  49. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  50. package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  51. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  52. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
  53. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
  54. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  55. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  56. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  57. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  58. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  59. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  60. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  61. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  62. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  63. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  64. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  65. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
  66. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  67. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  68. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  69. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  70. package/src/shared/components/ui/tooltip.tsx +16 -1
  71. package/src/shared/hooks/use-config.ts +11 -0
  72. package/src/shared/lib/api/types.ts +8 -0
  73. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  74. package/src/shared/lib/i18n/locales/en-US/chat.json +7 -0
  75. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  76. package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -0
  77. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  78. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  79. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  80. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  81. package/dist/assets/index-DUCA8m2w.js +0 -109
  82. package/dist/assets/index-QrdUWHfm.css +0 -1
  83. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  84. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  85. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  86. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  87. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  88. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  89. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  90. package/dist/assets/react-DVMmnPWz.js +0 -8
  91. package/dist/assets/remote-DFoxj4ls.js +0 -1
  92. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  93. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  94. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  95. 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 { ProjectAddExistingRequest, ProjectCreateRequest } from "@/shared/lib/api";
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(items),
164
- [items],
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
- () => groupSessionsByProject(
172
- sortedItems,
173
- pinnedSessionKeys,
177
+ () =>
178
+ groupSessionsByProject(
179
+ sortedItems,
180
+ pinnedSessionKeys,
181
+ pinnedProjectRoots,
182
+ projectsQuery.data?.projects ?? [],
183
+ ),
184
+ [
174
185
  pinnedProjectRoots,
175
- projectsQuery.data?.projects ?? [],
176
- ),
177
- [pinnedProjectRoots, pinnedSessionKeys, projectsQuery.data?.projects, sortedItems],
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 (input: ProjectCreateRequest): Promise<void> => {
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 { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
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 type {
3
- UiNcpSessionPendingInputView,
4
- UiNcpSessionQueuedInputView,
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 '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';
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(sessions: readonly SessionEntryView[], query: string): SessionEntryView[] {
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(params: { limit?: number; query?: string | null } = {}) {
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 items = useMemo<NcpSessionListItemView[]>(() => {
35
+ const allItems = useMemo<NcpSessionListItemView[]>(() => {
31
36
  const summaries = sessionsQuery.data?.sessions ?? [];
32
- const sessions = adaptNcpSessionSummaries(summaries).filter(
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 === 'running' ? 'running' : undefined
39
+ runStatus: session.status === "running" ? "running" : undefined,
40
40
  }));
41
- }, [query, sessionsQuery.data?.sessions]);
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
  }
@@ -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(screen.getByText("analysis-project").nextElementSibling?.tagName).toBe("svg");
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
 
@@ -1,17 +1,17 @@
1
- import { render, screen } 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';
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('@nextclaw/agent-chat-ui', () => ({
10
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
11
11
  copyText: mocks.copyText,
12
12
  }));
13
13
 
14
- vi.mock('sonner', () => ({
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: 'runtime-image',
32
- src: '/runtime-icons/codex-openai.svg',
33
- name: 'Codex',
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('shows session actions only on hover or when an action owns focus', () => {
55
- screen.getByText('Current Task').closest('button')?.focus();
56
- const actions = screen.getByLabelText('Pin session').parentElement;
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('opacity-0');
59
- expect(actions?.className).toContain('group-hover/session:opacity-100');
60
- expect(actions?.className).toContain('focus-within:opacity-100');
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
- 'group-focus-within/session:opacity-100',
67
+ "group-focus-within/session:opacity-100",
63
68
  );
64
- expect(screen.getByRole('button', { name: 'More actions' })).toBeTruthy();
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('copies the sidebar session ID from the more-actions menu', async () => {
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('button', { name: 'More actions' }));
71
- await user.click(screen.getByRole('button', { name: 'Copy session ID' }));
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(mocks.copyText).toHaveBeenCalledWith('session:current');
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('sizes the runtime icon to the session title text', () => {
77
- const runtimeIcon = screen.getByRole('img', { name: 'Codex logo' });
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
- expect(runtimeIcon.parentElement?.className).toContain('h-[13px]');
80
- expect(runtimeIcon.parentElement?.className).toContain('w-[13px]');
81
- expect(runtimeIcon.parentElement?.parentElement?.className).toContain('h-4');
82
- expect(runtimeIcon.parentElement?.parentElement?.className).toContain('w-4');
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
+ }