@nextclaw/ui 0.25.2 → 0.25.4

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.
@@ -3,6 +3,7 @@ import { eventKeys } from '@nextclaw/shared';
3
3
  import { applyNcpSessionRealtimeEvent, nextclawClient } from '@/shared/lib/api';
4
4
  import { runtimeUpdateManager, systemStatusManager } from '@/features/system-status';
5
5
  import type { QueryClient } from '@tanstack/react-query';
6
+ import { useChatSessionListStore } from '@/features/chat';
6
7
 
7
8
  function shouldInvalidateConfigQuery(configPath: string) {
8
9
  const normalized = configPath.trim().toLowerCase();
@@ -55,7 +56,9 @@ export function useAppEventConsumers(queryClient?: QueryClient) {
55
56
  void runtimeUpdateManager.refreshAfterRealtimeReconnect();
56
57
  if (shouldResyncSessionsRef.current) {
57
58
  shouldResyncSessionsRef.current = false;
59
+ useChatSessionListStore.getState().clearSessionRunStatuses();
58
60
  queryClient?.invalidateQueries({ queryKey: ['ncp-sessions'] });
61
+ queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
59
62
  }
60
63
  });
61
64
  const unsubscribeConnectionClose = nextclawClient.eventBus.on(eventKeys.connectionClose, () => {
@@ -77,12 +80,14 @@ export function useAppEventConsumers(queryClient?: QueryClient) {
77
80
  runtimeUpdateManager.reportSnapshot(snapshot);
78
81
  });
79
82
  const unsubscribeSessionRunStatus = nextclawClient.eventBus.on(eventKeys.sessionRunStatus, (payload) => {
83
+ useChatSessionListStore.getState().setSessionRunStatus(payload.sessionKey, payload.status);
80
84
  applyNcpSessionRealtimeEvent(queryClient, { type: 'session.run-status', payload });
81
85
  });
82
86
  const unsubscribeSessionSummaryUpsert = nextclawClient.eventBus.on(eventKeys.sessionSummaryUpsert, (payload) => {
83
87
  applyNcpSessionRealtimeEvent(queryClient, { type: 'session.summary.upsert', payload });
84
88
  });
85
89
  const unsubscribeSessionSummaryDelete = nextclawClient.eventBus.on(eventKeys.sessionSummaryDelete, (payload) => {
90
+ useChatSessionListStore.getState().setSessionRunStatus(payload.sessionKey, 'idle');
86
91
  applyNcpSessionRealtimeEvent(queryClient, { type: 'session.summary.delete', payload });
87
92
  });
88
93
  const unsubscribeError = nextclawClient.eventBus.on(eventKeys.error, (payload) => {
@@ -1,5 +1,6 @@
1
- import { renderHook } from "@testing-library/react";
1
+ import { act, renderHook } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useNcpChildSessionTabsView } from "@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view";
3
4
  import { useNcpSessionListView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
4
5
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
5
6
  import type { NcpSessionSummaryView } from "@/shared/lib/api";
@@ -9,6 +10,11 @@ const mocks = vi.hoisted(() => ({
9
10
  }));
10
11
 
11
12
  vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-queries", () => ({
13
+ useNcpSessions: () => ({
14
+ data: {
15
+ sessions: mocks.sessions,
16
+ },
17
+ }),
12
18
  useInfiniteNcpSessions: () => ({
13
19
  data: {
14
20
  pages: [{
@@ -50,6 +56,7 @@ describe("useNcpSessionListView", () => {
50
56
  createSummary("session:beta", "Beta Task"),
51
57
  ];
52
58
  useChatSessionListStore.setState({
59
+ runningSessionKeys: [],
53
60
  snapshot: {
54
61
  ...useChatSessionListStore.getState().snapshot,
55
62
  query: "",
@@ -100,4 +107,40 @@ describe("useNcpSessionListView", () => {
100
107
  "session:alpha:child",
101
108
  ]);
102
109
  });
110
+
111
+ it("keeps a realtime running overlay when the persisted summary is still idle", () => {
112
+ useChatSessionListStore.getState().setSessionRunStatus("session:alpha", "running");
113
+
114
+ const { result } = renderHook(() => useNcpSessionListView());
115
+
116
+ expect(result.current.items[0]).toMatchObject({
117
+ session: { key: "session:alpha", status: "idle" },
118
+ runStatus: "running",
119
+ });
120
+ });
121
+
122
+ it("clears the realtime running overlay when the session becomes idle", () => {
123
+ useChatSessionListStore.getState().setSessionRunStatus("session:alpha", "running");
124
+ const { result } = renderHook(() => useNcpSessionListView());
125
+
126
+ act(() => {
127
+ useChatSessionListStore.getState().setSessionRunStatus("session:alpha", "idle");
128
+ });
129
+
130
+ expect(result.current.items[0]?.runStatus).toBeUndefined();
131
+ });
132
+
133
+ it("projects the same realtime running overlay into child session tabs", () => {
134
+ useChatSessionListStore.getState().setSessionRunStatus("session:alpha", "running");
135
+
136
+ const { result } = renderHook(() => useNcpChildSessionTabsView([{
137
+ sessionKey: "session:alpha",
138
+ parentSessionKey: "session:parent",
139
+ }]));
140
+
141
+ expect(result.current[0]).toMatchObject({
142
+ sessionKey: "session:alpha",
143
+ runStatus: "running",
144
+ });
145
+ });
103
146
  });
@@ -5,6 +5,7 @@ import { adaptNcpSessionSummaries } from "@/features/chat/features/session/utils
5
5
  import { resolveSessionTypeLabel } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
6
6
  import type { ChatChildSessionTab } from "@/features/chat/stores/chat-thread.store";
7
7
  import { useNcpSessions } from "@/features/chat/features/ncp/hooks/use-ncp-session-queries";
8
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
8
9
  import type { SessionRunStatus } from "@/features/chat/types/session-run-status.types";
9
10
 
10
11
  export type ResolvedChildSessionTab = {
@@ -39,6 +40,7 @@ export function useNcpChildSessionTabsView(
39
40
  tabs: readonly ChatChildSessionTab[],
40
41
  ): ResolvedChildSessionTab[] {
41
42
  const sessionsQuery = useNcpSessions({ limit: 200 });
43
+ const runningSessionKeys = useChatSessionListStore((state) => state.runningSessionKeys);
42
44
  const summaries = useMemo(
43
45
  () => sessionsQuery.data?.sessions ?? [],
44
46
  [sessionsQuery.data?.sessions],
@@ -48,6 +50,10 @@ export function useNcpChildSessionTabsView(
48
50
  const sessions = adaptNcpSessionSummaries(summaries);
49
51
  return new Map(sessions.map((session) => [session.key, session]));
50
52
  }, [summaries]);
53
+ const runningSessionKeySet = useMemo(
54
+ () => new Set(runningSessionKeys),
55
+ [runningSessionKeys],
56
+ );
51
57
 
52
58
  return useMemo(
53
59
  () =>
@@ -62,7 +68,9 @@ export function useNcpChildSessionTabsView(
62
68
  updatedAt: session?.updatedAt ?? null,
63
69
  lastMessageAt: session?.lastMessageAt ?? null,
64
70
  readAt: session?.readAt ?? null,
65
- runStatus: session?.status === "running" ? "running" : undefined,
71
+ runStatus: runningSessionKeySet.has(tab.sessionKey) || session?.status === "running"
72
+ ? "running"
73
+ : undefined,
66
74
  sessionTypeLabel: session?.sessionType
67
75
  ? resolveSessionTypeLabel(session.sessionType)
68
76
  : null,
@@ -71,6 +79,6 @@ export function useNcpChildSessionTabsView(
71
79
  projectRoot: session?.projectRoot?.trim() || null,
72
80
  };
73
81
  }),
74
- [sessionByKey, tabs],
82
+ [runningSessionKeySet, sessionByKey, tabs],
75
83
  );
76
84
  }
@@ -29,6 +29,7 @@ export function useNcpSessionListView(
29
29
  params: { limit?: number; query?: string | null } = {},
30
30
  ) {
31
31
  const storedQuery = useChatSessionListStore((state) => state.snapshot.query);
32
+ const runningSessionKeys = useChatSessionListStore((state) => state.runningSessionKeys);
32
33
  const query = params.query ?? storedQuery;
33
34
  const deferredQuery = useDeferredValue(query);
34
35
  const sessionsQuery = useInfiniteNcpSessions({
@@ -38,11 +39,14 @@ export function useNcpSessionListView(
38
39
 
39
40
  const allItems = useMemo<NcpSessionListItemView[]>(() => {
40
41
  const summaries = sessionsQuery.data?.pages.flatMap((page) => page.sessions) ?? [];
42
+ const runningSessionKeySet = new Set(runningSessionKeys);
41
43
  return adaptNcpSessionSummaries(summaries).map((session) => ({
42
44
  session,
43
- runStatus: session.status === "running" ? "running" : undefined,
45
+ runStatus: runningSessionKeySet.has(session.key) || session.status === "running"
46
+ ? "running"
47
+ : undefined,
44
48
  }));
45
- }, [sessionsQuery.data?.pages]);
49
+ }, [runningSessionKeys, sessionsQuery.data?.pages]);
46
50
  const items = useMemo<NcpSessionListItemView[]>(() => {
47
51
  const visibleItems = allItems.filter(({ session }) =>
48
52
  shouldShowSessionInSidebar(session),
@@ -78,8 +78,11 @@ export function shouldShowUnreadSessionIndicator(params: {
78
78
  type ChatSessionListStore = {
79
79
  snapshot: ChatSessionListSnapshot;
80
80
  optimisticReadAtBySessionKey: Record<string, string>;
81
+ runningSessionKeys: string[];
81
82
  setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
82
83
  markSessionRead: (sessionKey: string, readAt: string | null | undefined) => void;
84
+ setSessionRunStatus: (sessionKey: string, status: 'running' | 'idle') => void;
85
+ clearSessionRunStatuses: () => void;
83
86
  };
84
87
 
85
88
  type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0];
@@ -126,13 +129,36 @@ function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
126
129
  });
127
130
  }
128
131
 
132
+ function createSetSessionRunStatusAction(set: ChatSessionListStoreSet) {
133
+ return (sessionKey: string, status: 'running' | 'idle') =>
134
+ set((state) => {
135
+ const normalizedSessionKey = sessionKey.trim();
136
+ if (!normalizedSessionKey) {
137
+ return state;
138
+ }
139
+ const isRunning = state.runningSessionKeys.includes(normalizedSessionKey);
140
+ if ((status === 'running') === isRunning) {
141
+ return state;
142
+ }
143
+ return {
144
+ ...state,
145
+ runningSessionKeys: status === 'running'
146
+ ? [...state.runningSessionKeys, normalizedSessionKey]
147
+ : state.runningSessionKeys.filter((key) => key !== normalizedSessionKey),
148
+ };
149
+ });
150
+ }
151
+
129
152
  export const useChatSessionListStore = create<ChatSessionListStore>()(
130
153
  persist(
131
154
  (set) => ({
132
155
  snapshot: initialSnapshot,
133
156
  optimisticReadAtBySessionKey: {},
157
+ runningSessionKeys: [],
134
158
  setSnapshot: createSetSnapshotAction(set),
135
- markSessionRead: createMarkSessionReadAction(set)
159
+ markSessionRead: createMarkSessionReadAction(set),
160
+ setSessionRunStatus: createSetSessionRunStatusAction(set),
161
+ clearSessionRunStatuses: () => set({ runningSessionKeys: [] }),
136
162
  }),
137
163
  {
138
164
  name: CHAT_SESSION_LIST_MODE_STORAGE_KEY,