@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.
- package/CHANGELOG.md +26 -0
- package/dist/assets/{app-presenter-provider-DcpcRgLn.js → app-presenter-provider-De7Zwitu.js} +14 -14
- package/dist/assets/{appearance-settings-page-yJR6Kt9c.js → appearance-settings-page-BkQaPtQ-.js} +1 -1
- package/dist/assets/{chat-page-CXYXMIQQ.js → chat-page-AvOuaxuv.js} +1 -1
- package/dist/assets/{index-6Esxu-Pp.js → index-B5AoUrMf.js} +2 -2
- package/dist/assets/index-QveCjxCj.css +1 -0
- package/dist/assets/remote-B6TXClIg.js +1 -0
- package/dist/assets/{runtime-config-page-CMLVC5UV.js → runtime-config-page-BLrTVO3n.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +9 -9
- package/src/app/configs/app-navigation.config.ts +1 -1
- package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
- package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +12 -9
- package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +49 -41
- package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +1 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +9 -2
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
- package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +5 -1
- package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +24 -3
- package/src/platforms/mobile/components/mobile-bottom-nav.tsx +29 -32
- package/dist/assets/index-BzSgA8tR.css +0 -1
- package/dist/assets/remote-CPpTKDgX.js +0 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
-
import {
|
|
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
|
|
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).
|
|
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
|
|
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-
|
|
35
|
-
{
|
|
36
|
-
const active =
|
|
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-
|
|
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
|
-
<
|
|
50
|
-
data-testid={active ? "mobile-nav-active-indicator" : undefined}
|
|
44
|
+
<item.icon
|
|
51
45
|
className={cn(
|
|
52
|
-
"
|
|
53
|
-
active ? "
|
|
46
|
+
"h-3.5 w-3.5",
|
|
47
|
+
active ? "text-gray-900" : "text-gray-400",
|
|
54
48
|
)}
|
|
55
|
-
|
|
56
|
-
|
|
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
|
);
|