@nextclaw/ui 0.12.31 → 0.12.33

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 (66) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/assets/{api-CKKNxbsy.js → api-rASx2-N4.js} +1 -1
  3. package/dist/assets/{app-manager-provider-BM1hziPv.js → app-manager-provider-CUkcbdWk.js} +1 -1
  4. package/dist/assets/{channels-list-page-GD_IBCbY.js → channels-list-page-ZWYqfcCe.js} +1 -1
  5. package/dist/assets/{chat-page-Ty6QH2Tc.js → chat-page-ACm58eUO.js} +1 -1
  6. package/dist/assets/cpu-DKc3bQic.js +3 -0
  7. package/dist/assets/desktop-C-cDRCBY.js +3 -0
  8. package/dist/assets/{desktop-update-config-jWt63bXG.js → desktop-update-config-DAD9JN9Q.js} +1 -1
  9. package/dist/assets/{dialog-DWZeMf9V.js → dialog-CR80q3Rm.js} +1 -1
  10. package/dist/assets/{dist-DN-YM_0Y.js → dist-NBjcwlsw.js} +1 -1
  11. package/dist/assets/doc-browser-D2pq4IHR.js +1 -0
  12. package/dist/assets/doc-browser-DhIkcy0-.js +1 -0
  13. package/dist/assets/{doc-browser-context-FukQHvyo.js → doc-browser-context-BPeeiNJ_.js} +1 -1
  14. package/dist/assets/{doc-browser-CAhfnm0D.js → doc-browser-fLs0TcdS.js} +1 -1
  15. package/dist/assets/{es2015-ZJ-nWL4L.js → es2015-CVAuMqlM.js} +1 -1
  16. package/dist/assets/index-DV57tBmg.css +1 -0
  17. package/dist/assets/index-Db9Cufsy.js +100 -0
  18. package/dist/assets/{marketplace-page-DzwJTbAg.js → marketplace-page-CAp_gpNe.js} +2 -2
  19. package/dist/assets/marketplace-page-PPY5uMHK.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-87__92b1.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-BUMkGb6q.js → mcp-marketplace-page-C6HENB6V.js} +1 -1
  22. package/dist/assets/{model-config-Cgiw7ejv.js → model-config-CK3a8DWH.js} +1 -1
  23. package/dist/assets/{notice-card-BM2_YNoc.js → notice-card-CF4AJPM3.js} +1 -1
  24. package/dist/assets/{popover-2H9DS7Cv.js → popover-DeAliE_4.js} +1 -1
  25. package/dist/assets/{provider-scoped-model-input-DsZJ44ZV.js → provider-scoped-model-input-B8LJ6RCt.js} +1 -1
  26. package/dist/assets/{providers-list-BrViWRyR.js → providers-list-BTPjqNdK.js} +1 -1
  27. package/dist/assets/remote-DiPWGRZc.js +1 -0
  28. package/dist/assets/runtime-config-page-Bb9he7t8.js +1 -0
  29. package/dist/assets/{search-config-Deb56TtE.js → search-config-BkbSXa9d.js} +1 -1
  30. package/dist/assets/{secrets-config-JJkgNCnO.js → secrets-config-DFbHCb9n.js} +1 -1
  31. package/dist/assets/{select-C2c10F3e.js → select-HI5kiwX_.js} +1 -1
  32. package/dist/assets/{setting-row-CB_ZFbIZ.js → setting-row-B4QnghjV.js} +1 -1
  33. package/dist/assets/{tag-chip-CrQfVN3A.js → tag-chip-BPgMfp9l.js} +1 -1
  34. package/dist/assets/{use-config-jAYudJxy.js → use-config-BFqzVF8Y.js} +1 -1
  35. package/dist/assets/{use-infinite-scroll-loader-B97V1Ywd.js → use-infinite-scroll-loader-DXhw9W4l.js} +1 -1
  36. package/dist/index.html +16 -16
  37. package/package.json +9 -9
  38. package/src/app/components/layout/app-layout.test.tsx +58 -4
  39. package/src/app/components/layout/app-layout.tsx +2 -2
  40. package/src/app/components/theme-provider.tsx +0 -1
  41. package/src/app/hooks/use-viewport-layout.ts +0 -1
  42. package/src/features/chat/components/chat-session-workspace-panel.tsx +54 -51
  43. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +2 -1
  44. package/src/features/chat/managers/chat-session-list.manager.test.ts +6 -4
  45. package/src/features/chat/managers/chat-session-list.manager.ts +0 -6
  46. package/src/features/chat/utils/chat-message-invalid-tool-arguments.utils.test.ts +79 -0
  47. package/src/features/chat/utils/chat-message-part.utils.ts +34 -19
  48. package/src/main.tsx +5 -3
  49. package/src/platforms/desktop/components/desktop-app-shell.test.tsx +42 -8
  50. package/src/platforms/desktop/components/desktop-window-chrome.tsx +89 -13
  51. package/src/platforms/desktop/types/desktop-update.types.ts +6 -2
  52. package/src/shared/components/doc-browser/doc-browser-context.tsx +0 -27
  53. package/src/shared/components/doc-browser/doc-browser.test.tsx +11 -9
  54. package/src/shared/components/doc-browser/doc-browser.tsx +37 -63
  55. package/src/shared/components/resizable-right-panel/resizable-right-panel.test.tsx +38 -0
  56. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +46 -0
  57. package/dist/assets/cpu-DPPwMzoC.js +0 -3
  58. package/dist/assets/desktop-BwONApN9.js +0 -3
  59. package/dist/assets/doc-browser-p9DDNPWB.js +0 -1
  60. package/dist/assets/doc-browser-rZIQIjuw.js +0 -1
  61. package/dist/assets/index-B7CVI2ys.css +0 -1
  62. package/dist/assets/index-SS1xPoWD.js +0 -100
  63. package/dist/assets/marketplace-page-DZEljaFz.js +0 -1
  64. package/dist/assets/mcp-marketplace-page-NfOsALIL.js +0 -1
  65. package/dist/assets/remote-Dm6hKGew.js +0 -1
  66. package/dist/assets/runtime-config-page-CvvW9VBj.js +0 -1
@@ -30,6 +30,7 @@ import { usePresenter } from "@/features/chat/components/providers/chat-presente
30
30
  import { ChatSessionWorkspaceFilePreview } from "./chat-session-workspace-file-preview";
31
31
  import { AgentIdentityAvatar } from "@/shared/components/common/agent-identity";
32
32
  import { SessionCronJobContent } from "@/features/chat/components/workspace/session-cron-job-content";
33
+ import { ResizableRightPanel } from "@/shared/components/resizable-right-panel/resizable-right-panel";
33
34
  import { t } from "@/shared/lib/i18n";
34
35
  import { cn } from "@/shared/lib/utils";
35
36
 
@@ -314,63 +315,65 @@ export function ChatSessionWorkspacePanel({
314
315
  }
315
316
 
316
317
  return (
317
- <aside
318
+ <ResizableRightPanel
318
319
  className={cn(
319
320
  displayMode === "overlay"
320
- ? "fixed inset-0 z-40 flex bg-white"
321
- : "hidden shrink-0 border-l border-gray-200/70 bg-white/95 backdrop-blur-sm md:flex md:w-[26rem] lg:w-[30rem] xl:w-[34rem]",
321
+ ? "bg-white"
322
+ : "hidden border-gray-200/70 bg-white/95 backdrop-blur-sm md:flex",
322
323
  )}
324
+ defaultWidth={480}
325
+ minWidth={360}
326
+ maxWidth={860}
327
+ overlay={displayMode === "overlay"}
323
328
  >
324
- <div className="flex h-full min-h-0 w-full flex-col">
325
- <div className="flex items-center justify-between gap-3 border-b border-gray-200/70 px-4 py-2.5">
326
- <button
327
- type="button"
328
- onClick={presenter.chatThreadManager.goToParentSession}
329
- className={cn(
330
- "inline-flex items-center gap-1 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900",
331
- !hasParentSession && "pointer-events-none opacity-0",
332
- )}
333
- >
334
- <ArrowLeft className="h-3.5 w-3.5" />
335
- <span>{t("chatBackToParent")}</span>
336
- </button>
337
- <button
338
- type="button"
339
- onClick={presenter.chatThreadManager.closeWorkspacePanel}
340
- className="rounded-full border border-gray-200/80 p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
341
- aria-label={t("chatWorkspaceClosePanel")}
342
- >
343
- <X className="h-4 w-4" />
344
- </button>
345
- </div>
329
+ <div className="flex items-center justify-between gap-3 border-b border-gray-200/70 px-4 py-2.5">
330
+ <button
331
+ type="button"
332
+ onClick={presenter.chatThreadManager.goToParentSession}
333
+ className={cn(
334
+ "inline-flex items-center gap-1 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900",
335
+ !hasParentSession && "pointer-events-none opacity-0",
336
+ )}
337
+ >
338
+ <ArrowLeft className="h-3.5 w-3.5" />
339
+ <span>{t("chatBackToParent")}</span>
340
+ </button>
341
+ <button
342
+ type="button"
343
+ onClick={presenter.chatThreadManager.closeWorkspacePanel}
344
+ className="rounded-full border border-gray-200/80 p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
345
+ aria-label={t("chatWorkspaceClosePanel")}
346
+ >
347
+ <X className="h-4 w-4" />
348
+ </button>
349
+ </div>
346
350
 
347
- <WorkspaceTabsBar tabs={workspaceTabs} />
351
+ <WorkspaceTabsBar tabs={workspaceTabs} />
348
352
 
349
- <div className="flex min-h-0 flex-1 flex-col bg-white">
350
- {activeSelection.kind === "child-session" ? (
351
- <>
352
- <WorkspaceActiveChildHeader tab={activeSelection.tab} />
353
- <div className="flex-1 min-h-0">
354
- <ChildSessionContent
355
- sessionKey={activeSelection.tab.sessionKey}
356
- onToolAction={
357
- presenter.chatThreadManager.openSessionFromToolAction
358
- }
359
- onFileOpen={presenter.chatThreadManager.openFilePreview}
360
- />
361
- </div>
362
- </>
363
- ) : activeSelection.kind === "file" ? (
364
- <ChatSessionWorkspaceFilePreview
365
- file={activeSelection.file}
366
- sessionProjectRoot={sessionProjectRoot}
367
- onFileOpen={presenter.chatThreadManager.openFilePreview}
368
- />
369
- ) : (
370
- <SessionCronJobContent jobs={sessionCronJobs} />
371
- )}
372
- </div>
353
+ <div className="flex min-h-0 flex-1 flex-col bg-white">
354
+ {activeSelection.kind === "child-session" ? (
355
+ <>
356
+ <WorkspaceActiveChildHeader tab={activeSelection.tab} />
357
+ <div className="flex-1 min-h-0">
358
+ <ChildSessionContent
359
+ sessionKey={activeSelection.tab.sessionKey}
360
+ onToolAction={
361
+ presenter.chatThreadManager.openSessionFromToolAction
362
+ }
363
+ onFileOpen={presenter.chatThreadManager.openFilePreview}
364
+ />
365
+ </div>
366
+ </>
367
+ ) : activeSelection.kind === "file" ? (
368
+ <ChatSessionWorkspaceFilePreview
369
+ file={activeSelection.file}
370
+ sessionProjectRoot={sessionProjectRoot}
371
+ onFileOpen={presenter.chatThreadManager.openFilePreview}
372
+ />
373
+ ) : (
374
+ <SessionCronJobContent jobs={sessionCronJobs} />
375
+ )}
373
376
  </div>
374
- </aside>
377
+ </ResizableRightPanel>
375
378
  );
376
379
  }
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { ChatConversationPanel } from "@/features/chat/components/conversation/chat-conversation-panel";
5
- import { ChatSessionWorkspacePanel } from "@/features/chat";
5
+ import { ChatSessionWorkspacePanel } from "@/features/chat/components/chat-session-workspace-panel";
6
6
  import type { ResolvedChildSessionTab } from "@/features/chat/hooks/use-ncp-child-session-tabs-view";
7
7
  import type { CronJobView } from "@/shared/lib/api";
8
8
  import { useChatInputStore } from "@/features/chat/stores/chat-input.store";
@@ -556,6 +556,7 @@ describe("ChatSessionWorkspacePanel", () => {
556
556
  );
557
557
 
558
558
  expect(screen.queryByText("Child sessions")).toBeNull();
559
+ expect(screen.getByTestId("resizable-right-panel-handle")).toBeTruthy();
559
560
  expect(screen.getAllByText("北京天气")).toHaveLength(2);
560
561
  expect(screen.getByText("Codex")).toBeTruthy();
561
562
  expect(screen.getByText("openai/gpt-5.3-codex")).toBeTruthy();
@@ -42,8 +42,9 @@ describe('ChatSessionListManager', () => {
42
42
  snapshot: {
43
43
  ...useChatThreadStore.getState().snapshot,
44
44
  workspacePanelParentKey: 'session-1',
45
+ activeWorkspacePanelKind: 'child-session',
45
46
  activeChildSessionKey: 'child-session-1',
46
- activeWorkspaceFileKey: 'session-1::/tmp/demo.md',
47
+ activeWorkspaceFileKey: null,
47
48
  },
48
49
  });
49
50
  });
@@ -155,7 +156,7 @@ describe('ChatSessionListManager', () => {
155
156
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
156
157
  });
157
158
 
158
- it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
159
+ it('delegates existing-session selection to routing while preserving workspace panel state', () => {
159
160
  const uiManager = {
160
161
  goToChatRoot: vi.fn(),
161
162
  goToSession: vi.fn(),
@@ -170,8 +171,9 @@ describe('ChatSessionListManager', () => {
170
171
 
171
172
  expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
172
173
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
173
- expect(useChatThreadStore.getState().snapshot.workspacePanelParentKey).toBeNull();
174
- expect(useChatThreadStore.getState().snapshot.activeChildSessionKey).toBeNull();
174
+ expect(useChatThreadStore.getState().snapshot.workspacePanelParentKey).toBe('session-1');
175
+ expect(useChatThreadStore.getState().snapshot.activeWorkspacePanelKind).toBe('child-session');
176
+ expect(useChatThreadStore.getState().snapshot.activeChildSessionKey).toBe('child-session-1');
175
177
  expect(useChatThreadStore.getState().snapshot.activeWorkspaceFileKey).toBeNull();
176
178
  });
177
179
 
@@ -154,12 +154,6 @@ export class ChatSessionListManager {
154
154
  };
155
155
 
156
156
  selectSession = (sessionKey: string) => {
157
- useChatThreadStore.getState().setSnapshot({
158
- workspacePanelParentKey: null,
159
- activeWorkspacePanelKind: null,
160
- activeChildSessionKey: null,
161
- activeWorkspaceFileKey: null,
162
- });
163
157
  this.uiManager.goToSession(sessionKey);
164
158
  };
165
159
 
@@ -0,0 +1,79 @@
1
+ import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
2
+ import { adaptChatMessages } from "./chat-message.utils";
3
+ import type { ChatMessageSource } from "./chat-message.utils";
4
+
5
+ const defaultTexts = {
6
+ roleLabels: {
7
+ user: "You",
8
+ assistant: "Assistant",
9
+ tool: "Tool",
10
+ system: "System",
11
+ fallback: "Message",
12
+ },
13
+ reasoningLabel: "Reasoning",
14
+ toolCallLabel: "Tool Call",
15
+ toolResultLabel: "Tool Result",
16
+ toolInputLabel: "Input",
17
+ toolNoOutputLabel: "No output",
18
+ toolOutputLabel: "Output",
19
+ toolStatusPreparingLabel: "Preparing",
20
+ toolStatusRunningLabel: "Running",
21
+ toolStatusCompletedLabel: "Completed",
22
+ toolStatusFailedLabel: "Failed",
23
+ toolStatusCancelledLabel: "Cancelled",
24
+ imageAttachmentLabel: "Image attachment",
25
+ fileAttachmentLabel: "File attachment",
26
+ unknownPartLabel: "Unknown Part",
27
+ };
28
+
29
+ function adapt(uiMessages: UiMessage[]) {
30
+ return adaptChatMessages({
31
+ uiMessages: uiMessages as unknown as ChatMessageSource[],
32
+ formatTimestamp: (value) => `formatted:${value}`,
33
+ texts: defaultTexts,
34
+ });
35
+ }
36
+
37
+ it("renders invalid tool arguments as a failed argument card instead of a normal raw-args card", () => {
38
+ const rawArgumentsText = '{"task":"spawn","request">\n<||DSML||parameter: "final_reply"}';
39
+ const adapted = adapt([
40
+ {
41
+ id: "assistant-invalid-tool-args",
42
+ role: "assistant",
43
+ parts: [
44
+ {
45
+ type: "tool-invocation",
46
+ toolInvocation: {
47
+ status: ToolInvocationStatus.RESULT,
48
+ toolCallId: "call-invalid-args",
49
+ toolName: "sessions_spawn",
50
+ args: rawArgumentsText,
51
+ result: {
52
+ ok: false,
53
+ error: {
54
+ code: "invalid_tool_arguments",
55
+ message: "Tool arguments are invalid.",
56
+ toolCallId: "call-invalid-args",
57
+ toolName: "sessions_spawn",
58
+ rawArgumentsText,
59
+ issues: ["Invalid JSON"],
60
+ },
61
+ },
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ ]);
67
+
68
+ expect(adapted[0]?.parts[0]).toMatchObject({
69
+ type: "tool-card",
70
+ card: {
71
+ toolName: "sessions_spawn",
72
+ summary: "Invalid arguments: Invalid JSON",
73
+ input: rawArgumentsText,
74
+ output: "Invalid JSON",
75
+ statusTone: "error",
76
+ statusLabel: "Failed",
77
+ },
78
+ });
79
+ });
@@ -21,7 +21,6 @@ import { buildFileOperationCardData } from "./file-operation/card.utils";
21
21
  import { buildSessionRequestToolCard } from "./chat-message-session-request-tool-card.utils";
22
22
  import type {
23
23
  ChatMessagePartViewModel,
24
- ChatToolPartViewModel,
25
24
  } from "@nextclaw/agent-chat-ui";
26
25
  import type {
27
26
  ChatMessageAdapterTexts,
@@ -39,6 +38,17 @@ type ChatMessagePartAdapterParams = {
39
38
  texts: ChatMessageAdapterTexts;
40
39
  };
41
40
 
41
+ function readInvalidToolArgumentsError(value: unknown): Record<string, unknown> | null {
42
+ const error = typeof value === "object" && value !== null && !Array.isArray(value)
43
+ ? (value as { error?: unknown }).error
44
+ : null;
45
+ return typeof error === "object" &&
46
+ error !== null &&
47
+ !Array.isArray(error) &&
48
+ (error as { code?: unknown }).code === "invalid_tool_arguments"
49
+ ? error as Record<string, unknown>
50
+ : null;
51
+ }
42
52
 
43
53
  function buildReasoningPart(
44
54
  part: Extract<ChatMessagePartSource, { type: "reasoning" }>,
@@ -113,6 +123,10 @@ function buildToolInvocationPart(
113
123
  result: invocation.result,
114
124
  texts,
115
125
  });
126
+ const invalidArgsError = readInvalidToolArgumentsError(invocation.result);
127
+ const invalidArgsIssue = Array.isArray(invalidArgsError?.issues)
128
+ ? invalidArgsError.issues.find((issue): issue is string => typeof issue === "string" && issue.trim().length > 0)
129
+ : undefined;
116
130
  const fileOperationCardData = buildFileOperationCardData({
117
131
  toolName: invocation.toolName,
118
132
  status: invocation.status,
@@ -121,14 +135,14 @@ function buildToolInvocationPart(
121
135
  parsedArgs: invocation.parsedArgs,
122
136
  result: invocation.result,
123
137
  });
124
- const detail =
125
- fileOperationCardData?.summary ??
126
- summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
127
- const input = fileOperationCardData
128
- ? undefined
129
- : buildToolInvocationInput(invocation.args, invocation.parsedArgs);
138
+ const detail = invalidArgsIssue
139
+ ? `Invalid arguments: ${invalidArgsIssue}`
140
+ : fileOperationCardData?.summary ?? summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
141
+ const input = fileOperationCardData ? undefined : buildToolInvocationInput(invocation.args, invocation.parsedArgs);
130
142
  const rawResult =
131
- typeof invocation.error === "string" && invocation.error.trim()
143
+ invalidArgsIssue
144
+ ? invalidArgsIssue
145
+ : typeof invocation.error === "string" && invocation.error.trim()
132
146
  ? invocation.error.trim()
133
147
  : invocation.result != null
134
148
  ? stringifyUnknown(invocation.result).trim()
@@ -149,8 +163,8 @@ function buildToolInvocationPart(
149
163
  outputData: invocation.result,
150
164
  callId: invocation.toolCallId || undefined,
151
165
  hasResult: statusView.hasResult,
152
- statusTone: statusView.statusTone,
153
- statusLabel: statusView.statusLabel,
166
+ statusTone: invalidArgsError ? "error" : statusView.statusTone,
167
+ statusLabel: invalidArgsError ? texts.toolStatusFailedLabel : statusView.statusLabel,
154
168
  ...(fileOperationCardData?.fileOperation
155
169
  ? { fileOperation: fileOperationCardData.fileOperation }
156
170
  : {}),
@@ -217,17 +231,18 @@ function isToolInvocationPart(
217
231
  export function adaptChatMessagePart(
218
232
  params: ChatMessagePartAdapterParams,
219
233
  ): ChatMessagePartViewModel | null {
220
- if (isTextPart(params.part)) {
221
- return buildTextPart(params.part, params.inlineTokens);
234
+ const { inlineTokens, part, texts } = params;
235
+ if (isTextPart(part)) {
236
+ return buildTextPart(part, inlineTokens);
222
237
  }
223
- if (isReasoningPart(params.part)) {
224
- return buildReasoningPart(params.part, params.texts);
238
+ if (isReasoningPart(part)) {
239
+ return buildReasoningPart(part, texts);
225
240
  }
226
- if (isFilePart(params.part)) {
227
- return buildFilePart(params.part, params.texts);
241
+ if (isFilePart(part)) {
242
+ return buildFilePart(part, texts);
228
243
  }
229
- if (isToolInvocationPart(params.part)) {
230
- return buildToolInvocationPart(params.part, params.texts);
244
+ if (isToolInvocationPart(part)) {
245
+ return buildToolInvocationPart(part, texts);
231
246
  }
232
- return buildUnknownPart(params.part, params.texts);
247
+ return buildUnknownPart(part, texts);
233
248
  }
package/src/main.tsx CHANGED
@@ -1,18 +1,20 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
- import { BrowserRouter } from 'react-router-dom';
3
+ import { BrowserRouter, MemoryRouter } from 'react-router-dom';
4
4
  import App from './app';
5
5
  import { I18nProvider } from '@/components/providers/I18nProvider';
6
6
  import { ThemeProvider } from '@/components/providers/ThemeProvider';
7
7
  import './index.css';
8
8
 
9
+ const AppRouter = window.nextclawDesktop?.platform === 'win32' ? MemoryRouter : BrowserRouter;
10
+
9
11
  createRoot(document.getElementById('root')!).render(
10
12
  <StrictMode>
11
13
  <ThemeProvider>
12
14
  <I18nProvider>
13
- <BrowserRouter>
15
+ <AppRouter>
14
16
  <App />
15
- </BrowserRouter>
17
+ </AppRouter>
16
18
  </I18nProvider>
17
19
  </ThemeProvider>
18
20
  </StrictMode>
@@ -1,4 +1,4 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { act, render, screen } from "@testing-library/react";
2
2
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { DesktopAppShell } from "@/platforms/desktop/components/desktop-app-shell";
@@ -7,10 +7,22 @@ vi.mock("@/app/components/layout/sidebar", () => ({
7
7
  Sidebar: () => <aside data-testid="settings-sidebar">Settings Sidebar</aside>,
8
8
  }));
9
9
 
10
- function setDesktopPlatform(platform: string | null): void {
10
+ type WindowStateListener = (snapshot: { isMaximized: boolean }) => void;
11
+
12
+ let windowStateListener: WindowStateListener | null = null;
13
+
14
+ function setDesktopPlatform(platform: string | null, isMaximized = false): void {
15
+ windowStateListener = null;
11
16
  window.nextclawDesktop = platform
12
17
  ? ({
13
18
  platform,
19
+ getWindowState: async () => ({ isMaximized }),
20
+ onWindowStateChanged: (listener: WindowStateListener) => {
21
+ windowStateListener = listener;
22
+ return () => {
23
+ windowStateListener = null;
24
+ };
25
+ },
14
26
  } as typeof window.nextclawDesktop)
15
27
  : undefined;
16
28
  }
@@ -48,20 +60,42 @@ describe("DesktopAppShell", () => {
48
60
 
49
61
  const chrome = screen.getByTestId("desktop-window-chrome");
50
62
  const sidebarChrome = screen.getByTestId("desktop-window-chrome-sidebar");
51
- const mainChrome = screen.getByTestId("desktop-window-chrome-main");
63
+ const mainDragRegion = screen.getByTestId("desktop-window-chrome-main-drag-region");
64
+ const resizeStrip = screen.getByTestId("desktop-window-chrome-resize-strip");
65
+ const controls = screen.getByTestId("desktop-window-controls");
52
66
 
53
67
  expect(chrome).toBeTruthy();
54
68
  expect(chrome.parentElement?.style.getPropertyValue("--desktop-titlebar-height")).toBe("40px");
55
69
  expect(chrome.className).toContain("bg-secondary");
70
+ expect(chrome.className).toContain("border-b");
71
+ expect(chrome.className).toContain("desktop-window-drag");
56
72
  expect(sidebarChrome.className).toContain("w-[var(--desktop-sidebar-width)]");
57
- expect(sidebarChrome.className).not.toContain("border-b");
58
- expect(mainChrome.className).toContain("border-b");
59
- expect(mainChrome.className).toContain("desktop-window-drag");
60
- expect(mainChrome.className).toContain("mr-[var(--desktop-caption-safe-right)]");
61
- expect(mainChrome.className).not.toContain("pr-[var(--desktop-caption-safe-right)]");
73
+ expect(sidebarChrome.className).toContain("desktop-window-no-drag");
74
+ expect(mainDragRegion.className).toContain("flex-1");
75
+ expect(mainDragRegion.className).toContain("desktop-window-drag");
76
+ expect(resizeStrip.className).toContain("desktop-window-no-drag");
77
+ expect(resizeStrip.className).toContain("top-0");
78
+ expect(resizeStrip.className).toContain("h-1");
79
+ expect(controls.className).toContain("desktop-window-no-drag");
80
+ expect(screen.getByLabelText("Minimize").className).toContain("desktop-window-no-drag");
81
+ expect(screen.getByLabelText("Maximize").className).toContain("desktop-window-no-drag");
82
+ expect(screen.getByLabelText("Close").className).toContain("desktop-window-no-drag");
62
83
  expect(screen.getByTestId("app-content")).toBeTruthy();
63
84
  });
64
85
 
86
+ it("switches the Windows maximize button to restore while maximized", async () => {
87
+ renderDesktopShell("win32");
88
+
89
+ expect(await screen.findByLabelText("Maximize")).toBeTruthy();
90
+
91
+ act(() => {
92
+ windowStateListener?.({ isMaximized: true });
93
+ });
94
+
95
+ expect(screen.getByLabelText("Restore").className).toContain("desktop-window-no-drag");
96
+ expect(screen.queryByLabelText("Maximize")).toBeNull();
97
+ });
98
+
65
99
  it("keeps non-Windows desktop hosts on the existing shell shape", () => {
66
100
  renderDesktopShell("darwin");
67
101
 
@@ -1,30 +1,106 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Copy, Minus, Square, X } from "lucide-react";
1
3
  import { BrandHeader } from "@/shared/components/common/brand-header";
4
+ import type { DesktopWindowStateSnapshot } from "@/platforms/desktop/types/desktop-update.types";
5
+
6
+ type DesktopWindowControlAction = "minimize" | "toggle-maximize" | "close";
7
+
8
+ type WindowControlDefinition = {
9
+ action: DesktopWindowControlAction;
10
+ label: string;
11
+ icon: typeof Minus;
12
+ variant?: "danger";
13
+ };
14
+
15
+ const windowControls: WindowControlDefinition[] = [
16
+ { action: "minimize", label: "Minimize", icon: Minus },
17
+ { action: "close", label: "Close", icon: X, variant: "danger" },
18
+ ];
2
19
 
3
20
  export function DesktopWindowChrome() {
21
+ const isMaximized = useDesktopWindowMaximizedState();
22
+ const maximizeControl: WindowControlDefinition = isMaximized
23
+ ? { action: "toggle-maximize", label: "Restore", icon: Copy }
24
+ : { action: "toggle-maximize", label: "Maximize", icon: Square };
25
+
4
26
  return (
5
27
  <header
6
- className="flex h-[var(--desktop-titlebar-height)] shrink-0 bg-secondary"
28
+ className="desktop-window-drag relative flex h-[var(--desktop-titlebar-height)] shrink-0 border-b border-[#ebe7dc]/80 bg-secondary"
7
29
  data-testid="desktop-window-chrome"
8
30
  >
9
31
  <div
10
- className="desktop-window-drag flex h-full w-[var(--desktop-sidebar-width)] shrink-0 items-center bg-secondary pl-4 pr-3 text-secondary-foreground"
32
+ className="desktop-window-no-drag absolute left-0 right-0 top-0 h-1"
33
+ data-testid="desktop-window-chrome-resize-strip"
34
+ />
35
+ <div
36
+ className="desktop-window-no-drag relative z-10 flex h-full w-[var(--desktop-sidebar-width)] shrink-0 items-center bg-secondary pl-4 pr-3 text-secondary-foreground"
11
37
  data-testid="desktop-window-chrome-sidebar"
12
38
  >
13
- <div className="desktop-window-no-drag flex min-w-0 shrink-0 items-center">
14
- <BrandHeader
15
- className="flex min-w-0 items-center gap-2.5"
16
- density="chrome"
17
- />
18
- </div>
19
- <div className="min-w-0 flex-1 self-stretch" aria-hidden="true" />
39
+ <BrandHeader
40
+ className="flex min-w-0 shrink-0 items-center gap-2.5"
41
+ density="chrome"
42
+ />
20
43
  </div>
44
+ <div aria-hidden="true" className="desktop-window-drag h-full min-w-0 flex-1" data-testid="desktop-window-chrome-main-drag-region" />
21
45
  <div
22
- className="desktop-window-drag mr-[var(--desktop-caption-safe-right)] flex min-w-0 flex-1 items-center border-b border-[#ebe7dc]/80 bg-secondary"
23
- aria-hidden="true"
24
- data-testid="desktop-window-chrome-main"
46
+ className="desktop-window-no-drag absolute right-0 top-0 z-20 flex h-full w-[var(--desktop-caption-safe-right)] items-start justify-end"
47
+ data-testid="desktop-window-controls"
25
48
  >
26
- <div className="min-w-0 flex-1 self-stretch" />
49
+ {[windowControls[0], maximizeControl, windowControls[1]].map((control) => (
50
+ <DesktopWindowControlButton key={control.action} control={control} />
51
+ ))}
27
52
  </div>
28
53
  </header>
29
54
  );
30
55
  }
56
+
57
+ function DesktopWindowControlButton({
58
+ control,
59
+ }: {
60
+ control: WindowControlDefinition;
61
+ }) {
62
+ const Icon = control.icon;
63
+
64
+ return (
65
+ <button
66
+ type="button"
67
+ aria-label={control.label}
68
+ title={control.label}
69
+ className={
70
+ control.variant === "danger"
71
+ ? "desktop-window-no-drag flex h-10 w-[46px] items-center justify-center text-gray-700 transition-colors hover:bg-red-500 hover:text-white"
72
+ : "desktop-window-no-drag flex h-10 w-[46px] items-center justify-center text-gray-700 transition-colors hover:bg-black/10"
73
+ }
74
+ onClick={() => {
75
+ void window.nextclawDesktop?.controlWindow?.(control.action);
76
+ }}
77
+ >
78
+ <Icon className="h-3.5 w-3.5" />
79
+ </button>
80
+ );
81
+ }
82
+
83
+ function useDesktopWindowMaximizedState(): boolean {
84
+ const [isMaximized, setIsMaximized] = useState(false);
85
+
86
+ useEffect(() => {
87
+ let isSubscribed = true;
88
+ const desktopApi = window.nextclawDesktop;
89
+
90
+ void desktopApi?.getWindowState?.().then((snapshot: DesktopWindowStateSnapshot) => {
91
+ if (isSubscribed) {
92
+ setIsMaximized(snapshot.isMaximized);
93
+ }
94
+ });
95
+ const unsubscribe = desktopApi?.onWindowStateChanged?.((snapshot: DesktopWindowStateSnapshot) => {
96
+ setIsMaximized(snapshot.isMaximized);
97
+ });
98
+
99
+ return () => {
100
+ isSubscribed = false;
101
+ unsubscribe?.();
102
+ };
103
+ }, []);
104
+
105
+ return isMaximized;
106
+ }
@@ -48,7 +48,9 @@ export type DesktopPresenceSnapshot = DesktopPresencePreferences & {
48
48
 
49
49
  export type DesktopUiLanguagePreference = 'en' | 'zh';
50
50
 
51
- export type DesktopShellTheme = 'warm' | 'cool';
51
+ export type DesktopWindowControlAction = 'minimize' | 'toggle-maximize' | 'close';
52
+
53
+ export type DesktopWindowStateSnapshot = { isMaximized: boolean };
52
54
 
53
55
  export type NextClawDesktopBridge = {
54
56
  platform: string;
@@ -65,6 +67,8 @@ export type NextClawDesktopBridge = {
65
67
  getPresenceState: () => Promise<DesktopPresenceSnapshot>;
66
68
  updatePresencePreferences: (preferences: Partial<DesktopPresencePreferences>) => Promise<DesktopPresenceSnapshot>;
67
69
  setLocalePreference?: (language: DesktopUiLanguagePreference | null) => Promise<DesktopUiLanguagePreference | null>;
68
- setShellTheme?: (theme: DesktopShellTheme) => Promise<void>;
70
+ controlWindow?: (action: DesktopWindowControlAction) => Promise<void>;
71
+ getWindowState?: () => Promise<DesktopWindowStateSnapshot>;
72
+ onWindowStateChanged?: (listener: (snapshot: DesktopWindowStateSnapshot) => void) => () => void;
69
73
  onUpdateStateChanged: (listener: (snapshot: DesktopUpdateSnapshot) => void) => () => void;
70
74
  };