@nextclaw/ui 0.15.16 → 0.15.18

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 (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/assets/{app-presenter-provider-DcpcRgLn.js → app-presenter-provider-De7Zwitu.js} +14 -14
  3. package/dist/assets/{appearance-settings-page-yJR6Kt9c.js → appearance-settings-page-BkQaPtQ-.js} +1 -1
  4. package/dist/assets/{chat-page-CXYXMIQQ.js → chat-page-AvOuaxuv.js} +1 -1
  5. package/dist/assets/{index-6Esxu-Pp.js → index-B5AoUrMf.js} +2 -2
  6. package/dist/assets/index-QveCjxCj.css +1 -0
  7. package/dist/assets/remote-B6TXClIg.js +1 -0
  8. package/dist/assets/{runtime-config-page-CMLVC5UV.js → runtime-config-page-BLrTVO3n.js} +1 -1
  9. package/dist/index.html +3 -3
  10. package/package.json +9 -9
  11. package/src/app/configs/app-navigation.config.ts +1 -1
  12. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
  13. package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
  14. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
  15. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
  16. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
  17. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
  18. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
  19. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
  20. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
  21. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
  22. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
  23. package/src/features/chat/features/message/components/chat-message-list.container.tsx +12 -9
  24. package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +49 -41
  25. package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +1 -1
  26. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
  27. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +9 -2
  28. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
  29. package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +5 -1
  30. package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +24 -3
  31. package/src/platforms/mobile/components/mobile-bottom-nav.tsx +29 -32
  32. package/dist/assets/index-BzSgA8tR.css +0 -1
  33. package/dist/assets/remote-CPpTKDgX.js +0 -1
@@ -1,5 +1,10 @@
1
1
  import { act, renderHook, waitFor } from "@testing-library/react";
2
- import { NcpEventType, type NcpAgentClientEndpoint } from "@nextclaw/ncp";
2
+ import {
3
+ NcpEventType,
4
+ type NcpAgentClientEndpoint,
5
+ type NcpEndpointSubscriber,
6
+ type NcpMessage,
7
+ } from "@nextclaw/ncp";
3
8
  import { useHydratedNcpAgent, useNcpAgentRuntime } from "@nextclaw/ncp-react";
4
9
  import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
5
10
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -147,4 +152,120 @@ describe("useHydratedNcpAgent", () => {
147
152
  });
148
153
  expect(result.current.visibleMessages).toEqual([historyMessage]);
149
154
  });
155
+
156
+ it("starts the live stream when a draft manager already contains the materialized session", async () => {
157
+ let subscriber: NcpEndpointSubscriber | null = null;
158
+ const client = {
159
+ stop: mocks.stop.mockResolvedValue(undefined),
160
+ stream: mocks.stream.mockImplementation(() => new Promise(() => {})),
161
+ subscribe: vi.fn((nextSubscriber: NcpEndpointSubscriber) => {
162
+ subscriber = nextSubscriber;
163
+ return () => {};
164
+ }),
165
+ } as unknown as NcpAgentClientEndpoint;
166
+ const loadSeed = vi.fn().mockResolvedValue({
167
+ messages: [],
168
+ status: "idle",
169
+ });
170
+ const materializedMessage: NcpMessage = {
171
+ id: "message-materialized",
172
+ sessionId: "session-materialized",
173
+ role: "user",
174
+ status: "final",
175
+ parts: [{ type: "text", text: "Visible immediately" }],
176
+ timestamp: "2026-07-24T00:00:00.000Z",
177
+ };
178
+ const { result, rerender } = renderHook(
179
+ ({ sessionId }: { sessionId?: string }) =>
180
+ useHydratedNcpAgent({
181
+ sessionId,
182
+ client: client as never,
183
+ loadSeed,
184
+ }),
185
+ { initialProps: { sessionId: undefined as string | undefined } },
186
+ );
187
+
188
+ await waitFor(() => {
189
+ expect(subscriber).not.toBeNull();
190
+ });
191
+ act(() => {
192
+ subscriber?.({
193
+ type: NcpEventType.MessageSent,
194
+ payload: {
195
+ sessionId: "session-materialized",
196
+ message: materializedMessage,
197
+ },
198
+ });
199
+ });
200
+ await waitFor(() => {
201
+ expect(result.current.visibleMessages).toEqual([materializedMessage]);
202
+ });
203
+
204
+ rerender({ sessionId: "session-materialized" });
205
+
206
+ await waitFor(() => {
207
+ expect(result.current.isHydrating).toBe(false);
208
+ });
209
+ expect(loadSeed).not.toHaveBeenCalled();
210
+ expect(mocks.stream).toHaveBeenCalledWith({
211
+ sessionId: "session-materialized",
212
+ });
213
+ });
214
+
215
+ it("reloads missed state and reconnects after the live stream disconnects", async () => {
216
+ vi.useFakeTimers();
217
+ try {
218
+ const connected = new Promise<void>(() => {});
219
+ const client = {
220
+ stop: mocks.stop.mockResolvedValue(undefined),
221
+ stream: mocks.stream
222
+ .mockRejectedValueOnce(new Error("stream disconnected"))
223
+ .mockImplementationOnce(() => connected),
224
+ subscribe: vi.fn(() => () => {}),
225
+ send: mocks.send,
226
+ } as unknown as NcpAgentClientEndpoint;
227
+ const recoveredMessage = {
228
+ id: "message-recovered",
229
+ sessionId: "session-recovery",
230
+ role: "assistant",
231
+ status: "final",
232
+ parts: [{ type: "text", text: "Recovered" }],
233
+ timestamp: "2026-07-24T00:01:00.000Z",
234
+ } as const;
235
+ const loadSeed = vi
236
+ .fn()
237
+ .mockResolvedValueOnce({ messages: [], status: "running" })
238
+ .mockResolvedValueOnce({
239
+ messages: [recoveredMessage],
240
+ status: "idle",
241
+ });
242
+ const { result } = renderHook(() =>
243
+ useHydratedNcpAgent({
244
+ sessionId: "session-recovery",
245
+ client: client as never,
246
+ loadSeed,
247
+ }),
248
+ );
249
+
250
+ await vi.waitFor(() => {
251
+ expect(result.current.hydrateError?.message).toBe(
252
+ "stream disconnected",
253
+ );
254
+ });
255
+ await act(async () => {
256
+ await vi.runOnlyPendingTimersAsync();
257
+ });
258
+ await vi.waitFor(() => {
259
+ expect(result.current.visibleMessages).toEqual([recoveredMessage]);
260
+ });
261
+
262
+ expect(loadSeed).toHaveBeenCalledTimes(2);
263
+ expect(mocks.stream).toHaveBeenCalledTimes(2);
264
+ expect(mocks.send).not.toHaveBeenCalled();
265
+ expect(result.current.hydrateError).toBeNull();
266
+ expect(result.current.isRunning).toBe(false);
267
+ } finally {
268
+ vi.useRealTimers();
269
+ }
270
+ });
150
271
  });
@@ -5,7 +5,7 @@ import { useNcpAgentRuntime } from '@nextclaw/ncp-react';
5
5
  import { DefaultNcpAgentConversationStateManager } from '@nextclaw/ncp-toolkit';
6
6
 
7
7
  describe('useNcpAgentRuntime backend queue submission', () => {
8
- it('forwards a new message while a run is active and waits for backend events to add it', async () => {
8
+ it('forwards and immediately shows a new message while a run is active', async () => {
9
9
  const manager = new DefaultNcpAgentConversationStateManager();
10
10
  manager.hydrate({ sessionId: 'session-1', messages: [] });
11
11
  await manager.dispatch({
@@ -46,6 +46,13 @@ describe('useNcpAgentRuntime backend queue submission', () => {
46
46
  parts: [{ type: 'text', text: 'queue this' }],
47
47
  }),
48
48
  }));
49
- expect(result.current.visibleMessages).toEqual([]);
49
+ expect(result.current.visibleMessages).toEqual([
50
+ expect.objectContaining({
51
+ sessionId: 'session-1',
52
+ role: 'user',
53
+ status: 'final',
54
+ parts: [{ type: 'text', text: 'queue this' }],
55
+ }),
56
+ ]);
50
57
  });
51
58
  });
@@ -200,12 +200,118 @@ describe("useNcpAgentRuntime", () => {
200
200
  assistantMessageId: "assistant-1",
201
201
  runId: "run-1",
202
202
  });
203
- expect(result.current.visibleMessages).toEqual([]);
203
+ expect(result.current.visibleMessages).toEqual([
204
+ expect.objectContaining({
205
+ ...envelope.message,
206
+ sessionId: "session-created",
207
+ }),
208
+ ]);
204
209
  expect(client.stop).not.toHaveBeenCalled();
205
210
 
206
211
  rerender({ sessionId: "session-created" });
207
212
  });
208
213
 
214
+ it("shows an existing-session user message before send returns and deduplicates the server event", async () => {
215
+ const client = new DeferredSendClient();
216
+ const manager = new DefaultNcpAgentConversationStateManager();
217
+ const envelope: NcpAgentSendEnvelope = {
218
+ sessionId: "session-existing",
219
+ message: {
220
+ id: "user-optimistic",
221
+ sessionId: "session-existing",
222
+ role: "user",
223
+ status: "final",
224
+ parts: [{ type: "text", text: "hello now" }],
225
+ timestamp: now,
226
+ },
227
+ };
228
+ let releaseSend = () => {};
229
+ client.send.mockImplementationOnce(
230
+ (pendingEnvelope) =>
231
+ new Promise((resolve) => {
232
+ releaseSend = () =>
233
+ resolve({
234
+ sessionId: "session-existing",
235
+ userMessageId: pendingEnvelope.message.id,
236
+ assistantMessageId: "assistant-1",
237
+ runId: "run-1",
238
+ });
239
+ }),
240
+ );
241
+ const { result } = renderHook(() =>
242
+ useNcpAgentRuntime({
243
+ sessionId: "session-existing",
244
+ client,
245
+ manager: manager as never,
246
+ }),
247
+ );
248
+
249
+ let sendPromise!: ReturnType<typeof result.current.send>;
250
+ act(() => {
251
+ sendPromise = result.current.send(envelope);
252
+ });
253
+
254
+ await waitFor(() => {
255
+ expect(result.current.visibleMessages).toEqual([envelope.message]);
256
+ });
257
+
258
+ await act(async () => {
259
+ await client.emit({
260
+ type: NcpEventType.MessageSent,
261
+ payload: {
262
+ sessionId: "session-existing",
263
+ message: envelope.message as NcpMessage,
264
+ },
265
+ });
266
+ });
267
+ await waitFor(() => {
268
+ expect(result.current.visibleMessages).toHaveLength(1);
269
+ });
270
+
271
+ await act(async () => {
272
+ releaseSend();
273
+ await sendPromise;
274
+ });
275
+ expect(result.current.visibleMessages).toEqual([envelope.message]);
276
+ });
277
+
278
+ it("marks an optimistic user message as failed when send fails", async () => {
279
+ const client = new DeferredSendClient();
280
+ const manager = new DefaultNcpAgentConversationStateManager();
281
+ const envelope: NcpAgentSendEnvelope = {
282
+ sessionId: "session-existing",
283
+ message: {
284
+ id: "user-failed",
285
+ sessionId: "session-existing",
286
+ role: "user",
287
+ status: "final",
288
+ parts: [{ type: "text", text: "please retry" }],
289
+ timestamp: now,
290
+ },
291
+ };
292
+ client.send.mockRejectedValueOnce(new Error("network unavailable"));
293
+ const { result } = renderHook(() =>
294
+ useNcpAgentRuntime({
295
+ sessionId: "session-existing",
296
+ client,
297
+ manager: manager as never,
298
+ }),
299
+ );
300
+
301
+ await act(async () => {
302
+ await expect(result.current.send(envelope)).rejects.toThrow(
303
+ "network unavailable",
304
+ );
305
+ });
306
+
307
+ expect(result.current.visibleMessages).toEqual([
308
+ expect.objectContaining({
309
+ ...envelope.message,
310
+ status: "error",
311
+ }),
312
+ ]);
313
+ });
314
+
209
315
  it("aborts by session id even before a hydrated active run reaches local state", async () => {
210
316
  const client = new DeferredSendClient();
211
317
  const manager = new DefaultNcpAgentConversationStateManager();
@@ -1,9 +1,13 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
  import { MemoryRouter } from "react-router-dom";
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import { I18nProvider } from "@/app/components/i18n-provider";
5
5
  import { MobileAppShell } from "@/platforms/mobile/components/mobile-app-shell";
6
6
 
7
+ vi.mock("@/shared/components/doc-browser", () => ({
8
+ useDocBrowser: () => ({ open: vi.fn() }),
9
+ }));
10
+
7
11
  function renderShell(pathname: string) {
8
12
  const view = render(
9
13
  <I18nProvider>
@@ -1,9 +1,16 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { MemoryRouter } from "react-router-dom";
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import { I18nProvider } from "@/app/components/i18n-provider";
5
5
  import { MobileBottomNav } from "@/platforms/mobile/components/mobile-bottom-nav";
6
6
 
7
+ const { openAppsMock } = vi.hoisted(() => ({ openAppsMock: vi.fn() }));
8
+
9
+ vi.mock("@/features/panel-apps", () => ({ openApps: openAppsMock }));
10
+ vi.mock("@/shared/components/doc-browser", () => ({
11
+ useDocBrowser: () => ({ open: vi.fn() }),
12
+ }));
13
+
7
14
  describe("MobileBottomNav", () => {
8
15
  it("highlights the settings tab for nested settings routes", () => {
9
16
  render(
@@ -21,7 +28,7 @@ describe("MobileBottomNav", () => {
21
28
  screen.getByRole("link", { name: /chat/i }).getAttribute("aria-current"),
22
29
  ).toBeNull();
23
30
  expect(screen.getByTestId("mobile-nav-active-indicator")).toBeTruthy();
24
- expect(screen.getByRole("link", { name: /settings/i }).className).not.toContain(
31
+ expect(screen.getByRole("link", { name: /settings/i }).className).toContain(
25
32
  "bg-gray-100",
26
33
  );
27
34
  expect(screen.getByTestId("mobile-nav-active-indicator").textContent).toMatch(
@@ -42,4 +49,18 @@ describe("MobileBottomNav", () => {
42
49
  screen.getByRole("link", { name: /chat/i }).getAttribute("aria-current"),
43
50
  ).toBe("page");
44
51
  });
52
+
53
+ it("opens the apps panel from the mobile navigation", () => {
54
+ render(
55
+ <I18nProvider>
56
+ <MemoryRouter initialEntries={["/chat"]}>
57
+ <MobileBottomNav />
58
+ </MemoryRouter>
59
+ </I18nProvider>,
60
+ );
61
+
62
+ fireEvent.click(screen.getByRole("button", { name: "Apps" }));
63
+
64
+ expect(openAppsMock).toHaveBeenCalledOnce();
65
+ });
45
66
  });
@@ -1,26 +1,18 @@
1
+ import { Boxes } from "lucide-react";
1
2
  import { Link, useLocation } from "react-router-dom";
2
3
  import {
3
4
  getMobileBottomNavItems,
4
5
  isSettingsRoute,
6
+ matchesRouteTarget,
5
7
  } from "@/app/configs/app-navigation.config";
8
+ import { openApps } from "@/features/panel-apps";
9
+ import { useDocBrowser } from "@/shared/components/doc-browser";
6
10
  import { t } from "@/shared/lib/i18n";
7
11
  import { cn } from "@/shared/lib/utils";
8
12
 
9
- function isNavItemActive(pathname: string, target: string): boolean {
10
- if (target === "/settings") {
11
- return isSettingsRoute(pathname);
12
- }
13
- const normalizedPath = pathname.toLowerCase();
14
- const normalizedTarget = target.toLowerCase();
15
- return (
16
- normalizedPath === normalizedTarget ||
17
- normalizedPath.startsWith(`${normalizedTarget}/`)
18
- );
19
- }
20
-
21
13
  export function MobileBottomNav() {
22
14
  const { pathname } = useLocation();
23
- const navItems = getMobileBottomNavItems(t);
15
+ const docBrowser = useDocBrowser();
24
16
 
25
17
  return (
26
18
  <nav
@@ -31,40 +23,45 @@ export function MobileBottomNav() {
31
23
  paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 0.25rem)",
32
24
  }}
33
25
  >
34
- <ul className="grid grid-cols-4 gap-1 px-2 pt-1">
35
- {navItems.map((item) => {
36
- const active = isNavItemActive(pathname, item.target);
26
+ <ul className="grid grid-cols-5 gap-1 px-2 pt-1">
27
+ {getMobileBottomNavItems(t).map((item) => {
28
+ const active = item.target === "/settings"
29
+ ? isSettingsRoute(pathname)
30
+ : matchesRouteTarget(pathname, item.target);
37
31
  return (
38
32
  <li key={item.target}>
39
33
  <Link
40
34
  to={item.target}
41
35
  aria-current={active ? "page" : undefined}
36
+ data-testid={active ? "mobile-nav-active-indicator" : undefined}
42
37
  className={cn(
43
- "group flex min-h-[2.875rem] items-center justify-center rounded-xl px-1 py-1 text-[10px] font-medium transition-colors",
38
+ "group flex min-h-[2.875rem] flex-col items-center justify-center gap-0.5 rounded-xl px-2 py-1 text-[10px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
44
39
  active
45
- ? "text-gray-900"
46
- : "text-gray-500 hover:text-gray-800",
40
+ ? "bg-gray-100 text-gray-900"
41
+ : "text-gray-500 hover:bg-gray-50 hover:text-gray-800",
47
42
  )}
48
43
  >
49
- <span
50
- data-testid={active ? "mobile-nav-active-indicator" : undefined}
44
+ <item.icon
51
45
  className={cn(
52
- "inline-flex max-w-full min-w-14 flex-col items-center justify-center gap-0.5 rounded-xl px-2 py-1 transition-colors",
53
- active ? "bg-gray-100" : "bg-transparent group-hover:bg-gray-50",
46
+ "h-3.5 w-3.5",
47
+ active ? "text-gray-900" : "text-gray-400",
54
48
  )}
55
- >
56
- <item.icon
57
- className={cn(
58
- "h-3.5 w-3.5",
59
- active ? "text-gray-900" : "text-gray-400",
60
- )}
61
- />
62
- <span className="max-w-full truncate">{item.label}</span>
63
- </span>
49
+ />
50
+ <span className="max-w-full truncate">{item.label}</span>
64
51
  </Link>
65
52
  </li>
66
53
  );
67
54
  })}
55
+ <li>
56
+ <button
57
+ type="button"
58
+ onClick={() => openApps(docBrowser)}
59
+ className="group flex min-h-[2.875rem] w-full flex-col items-center justify-center gap-0.5 rounded-xl px-2 py-1 text-[10px] font-medium text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
60
+ >
61
+ <Boxes className="h-3.5 w-3.5 text-gray-400" />
62
+ <span className="max-w-full truncate">{t("appsTitle")}</span>
63
+ </button>
64
+ </li>
68
65
  </ul>
69
66
  </nav>
70
67
  );