@nextclaw/ui 0.20.1 → 0.20.3

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 (138) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/channels-list-page-Drlt62kY.js +8 -0
  6. package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/index-C86hxgv5.css +1 -0
  18. package/dist/assets/index-CYBXR7zN.js +109 -0
  19. package/dist/assets/loader-circle-BlLuRFVW.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +9 -0
  22. package/dist/assets/model-config-page-DSeAjVT_.js +1 -0
  23. package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/privacy-settings-page-tN0Mt15x.js +1 -0
  26. package/dist/assets/providers-config-page-CgPrXR1u.js +1 -0
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/runtime-config-page-Crq96Jjq.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-Q6XfIj0h.js} +2 -2
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-gdaU9bE7.js} +1 -1
  40. package/dist/assets/x-CWdWBu_U.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +7 -7
  43. package/src/app/components/layout/sidebar.tsx +7 -0
  44. package/src/features/apps/components/app-package-card.tsx +29 -7
  45. package/src/features/apps/components/app-packages-panel.tsx +4 -0
  46. package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
  47. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
  48. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
  49. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +27 -6
  50. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +33 -5
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +31 -21
  54. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  55. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  57. package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  58. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +35 -6
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +43 -20
  62. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  65. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  67. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  68. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  69. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  70. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  73. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  74. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  75. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  77. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +65 -2
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  79. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  80. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  81. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  82. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  83. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  84. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  85. package/src/features/cron/components/cron-config.tsx +20 -46
  86. package/src/features/cron/components/cron-job-row.tsx +1 -1
  87. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  88. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  89. package/src/features/cron/index.ts +2 -0
  90. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  91. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  92. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  93. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  94. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  95. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  96. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  97. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  98. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  99. package/src/shared/components/ui/tooltip.tsx +16 -1
  100. package/src/shared/hooks/use-config.ts +11 -0
  101. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  102. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  103. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  104. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  105. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  106. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  107. package/src/shared/lib/api/types.ts +9 -0
  108. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  109. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  110. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -5
  111. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  112. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  113. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -5
  115. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  116. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  118. package/src/shared/lib/navigation-history/index.ts +1 -0
  119. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  120. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  121. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  122. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  123. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  124. package/dist/assets/index-DUCA8m2w.js +0 -109
  125. package/dist/assets/index-QrdUWHfm.css +0 -1
  126. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  127. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  128. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  129. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  130. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  131. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  132. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  133. package/dist/assets/react-DVMmnPWz.js +0 -8
  134. package/dist/assets/remote-DFoxj4ls.js +0 -1
  135. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  136. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  137. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  138. package/dist/assets/x-DThaS5ZL.js +0 -1
@@ -9,12 +9,20 @@ const mocks = vi.hoisted(() => ({
9
9
  }));
10
10
 
11
11
  vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-queries", () => ({
12
- useNcpSessions: () => ({
12
+ useInfiniteNcpSessions: () => ({
13
13
  data: {
14
- sessions: mocks.sessions,
15
- total: mocks.sessions.length,
14
+ pages: [{
15
+ sessions: mocks.sessions,
16
+ total: mocks.sessions.length,
17
+ page: 1,
18
+ pageSize: 100,
19
+ hasMore: false,
20
+ }],
16
21
  },
17
22
  isLoading: false,
23
+ hasNextPage: false,
24
+ isFetchingNextPage: false,
25
+ fetchNextPage: vi.fn(),
18
26
  }),
19
27
  }));
20
28
 
@@ -62,13 +70,34 @@ describe("useNcpSessionListView", () => {
62
70
  it("can override the hidden sidebar query for header switching", () => {
63
71
  useChatSessionListStore.getState().setSnapshot({ query: "Alpha" });
64
72
 
65
- const { result } = renderHook(() =>
66
- useNcpSessionListView({ query: "" }),
67
- );
73
+ const { result } = renderHook(() => useNcpSessionListView({ query: "" }));
68
74
 
69
75
  expect(result.current.items.map((item) => item.session.key)).toEqual([
70
76
  "session:alpha",
71
77
  "session:beta",
72
78
  ]);
73
79
  });
80
+
81
+ it("keeps hidden child sessions available for sidebar context counts", () => {
82
+ mocks.sessions.push({
83
+ ...createSummary("session:alpha:child", "Child Task"),
84
+ metadata: {
85
+ label: "Child Task",
86
+ session_type: "native",
87
+ parent_session_id: "session:alpha",
88
+ },
89
+ });
90
+
91
+ const { result } = renderHook(() => useNcpSessionListView());
92
+
93
+ expect(result.current.items.map((item) => item.session.key)).toEqual([
94
+ "session:alpha",
95
+ "session:beta",
96
+ ]);
97
+ expect(result.current.allItems.map((item) => item.session.key)).toEqual([
98
+ "session:alpha",
99
+ "session:beta",
100
+ "session:alpha:child",
101
+ ]);
102
+ });
74
103
  });
@@ -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 { useDeferredValue, 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 { useInfiniteNcpSessions } 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,46 @@ 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
- const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
33
+ const deferredQuery = useDeferredValue(query);
34
+ const sessionsQuery = useInfiniteNcpSessions({
35
+ pageSize: params.limit ?? 100,
36
+ query: deferredQuery,
37
+ });
29
38
 
39
+ const allItems = useMemo<NcpSessionListItemView[]>(() => {
40
+ const summaries = sessionsQuery.data?.pages.flatMap((page) => page.sessions) ?? [];
41
+ return adaptNcpSessionSummaries(summaries).map((session) => ({
42
+ session,
43
+ runStatus: session.status === "running" ? "running" : undefined,
44
+ }));
45
+ }, [sessionsQuery.data?.pages]);
30
46
  const items = useMemo<NcpSessionListItemView[]>(() => {
31
- const summaries = sessionsQuery.data?.sessions ?? [];
32
- const sessions = adaptNcpSessionSummaries(summaries).filter(
33
- shouldShowSessionInSidebar,
47
+ const visibleItems = allItems.filter(({ session }) =>
48
+ shouldShowSessionInSidebar(session),
49
+ );
50
+ const matchingSessionKeys = new Set(
51
+ filterSessionsByQuery(
52
+ visibleItems.map(({ session }) => session),
53
+ query,
54
+ ).map((session) => session.key),
34
55
  );
35
- const filteredSessions = filterSessionsByQuery(sessions, query);
36
56
 
37
- return filteredSessions.map((session) => ({
38
- session,
39
- runStatus: session.status === 'running' ? 'running' : undefined
40
- }));
41
- }, [query, sessionsQuery.data?.sessions]);
57
+ return visibleItems.filter(({ session }) =>
58
+ matchingSessionKeys.has(session.key),
59
+ );
60
+ }, [allItems, query]);
42
61
 
43
62
  return {
63
+ allItems,
44
64
  isLoading: sessionsQuery.isLoading,
45
- items
65
+ items,
66
+ hasMore: sessionsQuery.hasNextPage,
67
+ isLoadingMore: sessionsQuery.isFetchingNextPage,
68
+ loadMore: sessionsQuery.fetchNextPage,
46
69
  };
47
70
  }
@@ -1,4 +1,4 @@
1
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
  import {
3
3
  fetchNcpSessionObservations,
4
4
  fetchNcpSessionSkills,
@@ -21,6 +21,20 @@ export function useNcpSessions(params?: { limit?: number; peerId?: string }) {
21
21
  });
22
22
  }
23
23
 
24
+ export function useInfiniteNcpSessions(params: { pageSize: number; query?: string }) {
25
+ return useInfiniteQuery({
26
+ queryKey: ['ncp-session-pages', params.pageSize, params.query?.trim() || null],
27
+ initialPageParam: 1,
28
+ queryFn: ({ pageParam }) => fetchNcpSessions({
29
+ page: pageParam,
30
+ pageSize: params.pageSize,
31
+ ...(params.query?.trim() ? { query: params.query.trim() } : {}),
32
+ }),
33
+ getNextPageParam: (lastPage) => lastPage.hasMore ? (lastPage.page ?? 1) + 1 : undefined,
34
+ ...ncpSessionQueryDefaults,
35
+ });
36
+ }
37
+
24
38
  export function useNcpSessionSkills(params: {
25
39
  sessionId: string | null;
26
40
  projectRoot?: string | null;
@@ -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
+ }
@@ -1,15 +1,24 @@
1
- import { useMemo, useState, type ReactNode } from 'react';
2
- import { ChevronDown, ChevronRight, Folder, Pin, Plus } from 'lucide-react';
3
- import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import {
3
+ AlarmClock,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Folder,
7
+ MessageSquareText,
8
+ Pin,
9
+ Plus,
10
+ } from "lucide-react";
11
+ import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
4
12
  import { ChatSessionTypeMenu } from "@/features/chat/features/session-type/components/chat-session-type-menu";
5
- import { Popover, PopoverTrigger } from '@/shared/components/ui/popover';
6
- import { ChatPopoverContent } from '@/features/chat/components/chat-popover-content';
7
- import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
13
+ import { Popover, PopoverTrigger } from "@/shared/components/ui/popover";
14
+ import { ChatPopoverContent } from "@/features/chat/components/chat-popover-content";
15
+ import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
8
16
  import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
9
- import type { NcpSessionListItemView } from '@/features/chat/features/ncp/hooks/use-ncp-session-list-view';
10
- import type { ChatSidebarProjectGroup } from '@/features/chat/features/session/utils/chat-sidebar-session-groups.utils';
11
- import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
12
- import { t } from '@/shared/lib/i18n';
17
+ import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
18
+ import type { ChatSidebarProjectGroup } from "@/features/chat/features/session/utils/chat-sidebar-session-groups.utils";
19
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
20
+ import { t } from "@/shared/lib/i18n";
21
+ import { ChatSidebarContextCard } from "@/features/chat/features/session/components/chat-sidebar-context-card";
13
22
 
14
23
  export type { ChatSidebarProjectGroup };
15
24
 
@@ -20,80 +29,112 @@ type ChatSidebarProjectGroupsProps = {
20
29
  defaultSessionType: string;
21
30
  sessionTypeOptions: SessionTypeOption[];
22
31
  renderSessionItem: (item: NcpSessionListItemView) => ReactNode;
32
+ projectCronJobCountByRoot: ReadonlyMap<string, number>;
23
33
  };
24
34
 
25
35
  function resolveProjectGroupDefaultSessionType(
26
36
  defaultSessionType: string,
27
- sessionTypeOptions: SessionTypeOption[]
37
+ sessionTypeOptions: SessionTypeOption[],
28
38
  ): string {
29
- if (sessionTypeOptions.some((option) => option.value === defaultSessionType)) {
39
+ if (
40
+ sessionTypeOptions.some((option) => option.value === defaultSessionType)
41
+ ) {
30
42
  return defaultSessionType;
31
43
  }
32
44
  return sessionTypeOptions[0]?.value ?? defaultSessionType;
33
45
  }
34
46
 
35
47
  export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
36
- const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem } = props;
48
+ const {
49
+ groups,
50
+ defaultSessionType,
51
+ sessionTypeOptions,
52
+ renderSessionItem,
53
+ projectCronJobCountByRoot,
54
+ } = props;
37
55
  const presenter = usePresenter();
38
56
  const collapsedProjectRoots = useChatSessionListStore(
39
57
  (state) => state.snapshot.collapsedProjectRoots,
40
58
  );
41
59
  const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
42
60
  const preferredSessionType = useMemo(
43
- () => resolveProjectGroupDefaultSessionType(defaultSessionType, sessionTypeOptions),
44
- [defaultSessionType, sessionTypeOptions]
61
+ () =>
62
+ resolveProjectGroupDefaultSessionType(
63
+ defaultSessionType,
64
+ sessionTypeOptions,
65
+ ),
66
+ [defaultSessionType, sessionTypeOptions],
45
67
  );
46
68
  const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
47
69
 
48
70
  return (
49
71
  <div className="space-y-0.5">
50
72
  {groups.map((group) => {
51
- const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
73
+ const actionLabel = `${t("chatSidebarNewTask")} · ${group.projectName}`;
52
74
  const isCollapsed = collapsedProjectRoots.includes(group.projectRoot);
53
75
  const pinLabel = t(
54
- group.isPinned ? 'chatSidebarUnpinProject' : 'chatSidebarPinProject',
76
+ group.isPinned ? "chatSidebarUnpinProject" : "chatSidebarPinProject",
55
77
  );
56
78
 
57
79
  return (
58
80
  <div key={group.projectRoot}>
59
81
  <div className="group/project relative h-8 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900">
60
- <button
61
- type="button"
62
- aria-expanded={!isCollapsed}
63
- aria-label={t(
64
- isCollapsed
65
- ? 'chatSidebarExpandProject'
66
- : 'chatSidebarCollapseProject',
67
- )}
68
- className="flex h-full w-full min-w-0 items-center gap-1.5 pr-14 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
69
- onClick={() =>
70
- presenter.chatSessionListManager.toggleProjectCollapsed(
71
- group.projectRoot,
72
- )
73
- }
82
+ <ChatSidebarContextCard
83
+ title={group.projectName}
84
+ metrics={[
85
+ {
86
+ icon: <Folder className="h-3.5 w-3.5" />,
87
+ label: t("chatSidebarContextPath"),
88
+ value: group.projectRoot,
89
+ },
90
+ {
91
+ icon: <MessageSquareText className="h-3.5 w-3.5" />,
92
+ label: t("chatSidebarContextSessions"),
93
+ value: group.items.length,
94
+ },
95
+ {
96
+ icon: <AlarmClock className="h-3.5 w-3.5" />,
97
+ label: t("chatSidebarContextScheduledTasks"),
98
+ value:
99
+ projectCronJobCountByRoot.get(group.projectRoot) ?? 0,
100
+ },
101
+ ]}
74
102
  >
75
- <Folder className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
76
- <span
77
- className="truncate text-[11px] font-medium uppercase tracking-wider"
78
- title={group.projectRoot}
103
+ <button
104
+ type="button"
105
+ aria-expanded={!isCollapsed}
106
+ aria-label={t(
107
+ isCollapsed
108
+ ? "chatSidebarExpandProject"
109
+ : "chatSidebarCollapseProject",
110
+ )}
111
+ className="flex h-full w-full min-w-0 items-center gap-1.5 pr-14 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
112
+ onClick={() =>
113
+ presenter.chatSessionListManager.toggleProjectCollapsed(
114
+ group.projectRoot,
115
+ )
116
+ }
79
117
  >
80
- {group.projectName}
81
- </span>
82
- {isCollapsed ? (
83
- <ChevronRight
84
- className="h-3.5 w-3.5 shrink-0"
85
- aria-hidden="true"
86
- />
87
- ) : (
88
- <ChevronDown
89
- className="h-3.5 w-3.5 shrink-0"
90
- aria-hidden="true"
91
- />
92
- )}
93
- <span className="shrink-0 text-[10px] text-muted-foreground/70">
94
- {group.items.length}
95
- </span>
96
- </button>
118
+ <Folder className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
119
+ <span
120
+ className="truncate text-[11px] font-medium uppercase tracking-wider"
121
+ title={group.projectRoot}
122
+ >
123
+ {group.projectName}
124
+ </span>
125
+ {isCollapsed ? (
126
+ <ChevronRight
127
+ className="h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100"
128
+ aria-hidden="true"
129
+ />
130
+ ) : (
131
+ <ChevronDown
132
+ className="h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100"
133
+ aria-hidden="true"
134
+ />
135
+ )}
136
+ </button>
137
+ </ChatSidebarContextCard>
97
138
  <div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
98
139
  {supportsSessionTypeChoice ? (
99
140
  <Popover
@@ -142,8 +183,8 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
142
183
  <Pin
143
184
  className={
144
185
  group.isPinned
145
- ? 'h-3.5 w-3.5 fill-current text-foreground'
146
- : 'h-3.5 w-3.5'
186
+ ? "h-3.5 w-3.5 fill-current text-foreground"
187
+ : "h-3.5 w-3.5"
147
188
  }
148
189
  />
149
190
  }