@nextclaw/ui 0.14.4 → 0.15.0

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 (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/assets/api-DLtsiBKm.js +13 -0
  3. package/dist/assets/appearance-settings-page-CtkeYXux.js +1 -0
  4. package/dist/assets/book-open-BQwWBDKb.js +1 -0
  5. package/dist/assets/channels-list-page-Cv8o9Hxg.js +8 -0
  6. package/dist/assets/chat-page-BtA7H1Ea.js +1 -0
  7. package/dist/assets/{config-split-page-CRhlZ_pH.js → config-split-page-EidRBpP2.js} +1 -1
  8. package/dist/assets/confirm-dialog-iIJFFp2T.js +5 -0
  9. package/dist/assets/desktop-update-config-C6n_TB1r.js +1 -0
  10. package/dist/assets/{dist-CikkhOlq.js → dist-CBxWOazz.js} +1 -1
  11. package/dist/assets/{dist-PVA_xL1D.js → dist-D2eZC3OR.js} +1 -1
  12. package/dist/assets/doc-browser-B76XOXmW.js +1 -0
  13. package/dist/assets/doc-browser-DBnj9UMB.js +1 -0
  14. package/dist/assets/{doc-browser-context-MTw4EvKd.js → doc-browser-context-Sv-044Cl.js} +1 -1
  15. package/dist/assets/{ellipsis-y6vDtq4M.js → ellipsis-2uzhypw5.js} +1 -1
  16. package/dist/assets/es2015-DhQo_oS6.js +41 -0
  17. package/dist/assets/{external-link-DVJIQf_G.js → external-link-CM5dnUI6.js} +1 -1
  18. package/dist/assets/host-capabilities-9bB_998Z.js +1 -0
  19. package/dist/assets/i18n-provider-di1waTGX.js +1 -0
  20. package/dist/assets/index-CBRP-7-a.css +1 -0
  21. package/dist/assets/index-Z1fYpbNd.js +104 -0
  22. package/dist/assets/loader-circle-BjmN5DeJ.js +1 -0
  23. package/dist/assets/mcp-marketplace-page-C_MVQUbT.js +9 -0
  24. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +1 -0
  25. package/dist/assets/model-config-page-Cdr9P6TQ.js +1 -0
  26. package/dist/assets/plus-3ztRZD_l.js +1 -0
  27. package/dist/assets/provider-scoped-model-input-g1WkGiJ8.js +1 -0
  28. package/dist/assets/providers-config-page-CWlO7J4A.js +1 -0
  29. package/dist/assets/{react-VNqT5Zp7.js → react-DjpzoZXB.js} +1 -1
  30. package/dist/assets/refresh-cw-DaTv3zxE.js +1 -0
  31. package/dist/assets/remote-CKWZfVVB.js +1 -0
  32. package/dist/assets/rotate-cw-CHcaRUDz.js +1 -0
  33. package/dist/assets/runtime-config-page-CfTqh1nv.js +1 -0
  34. package/dist/assets/{save-BOJ477H-.js → save-BZof1uie.js} +1 -1
  35. package/dist/assets/{search-BEti5beA.js → search-MxAarS_g.js} +1 -1
  36. package/dist/assets/search-config-page-Dm8daYny.js +1 -0
  37. package/dist/assets/{secrets-config-page--IzrIOid.js → secrets-config-page-D8AbcR05.js} +2 -2
  38. package/dist/assets/security-config-Ds0LsJmo.js +1 -0
  39. package/dist/assets/select-D7GcNP0z.js +1 -0
  40. package/dist/assets/setting-row-CuozlnJd.js +1 -0
  41. package/dist/assets/side-dock-BcLau5S_.js +3 -0
  42. package/dist/assets/skeleton-shfmXuYp.js +1 -0
  43. package/dist/assets/switch-C1BWMU5c.js +1 -0
  44. package/dist/assets/tag-chip-DCUrQUCP.js +1 -0
  45. package/dist/assets/tooltip-DfAHHA76.js +1 -0
  46. package/dist/assets/use-config-DAdbT5QJ.js +1 -0
  47. package/dist/assets/use-confirm-dialog-eWcJ6V-s.js +1 -0
  48. package/dist/assets/x-DqL1mYLC.js +1 -0
  49. package/dist/index.html +53 -17
  50. package/index.html +25 -2
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/app-layout.test.tsx +27 -0
  53. package/src/app/components/layout/__tests__/settings-entry-page.test.tsx +9 -1
  54. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +6 -3
  55. package/src/app/components/layout/app-layout.tsx +10 -3
  56. package/src/app/components/layout/sidebar-items.tsx +8 -8
  57. package/src/app/components/layout/sidebar-rail.styles.ts +4 -2
  58. package/src/app/components/layout/sidebar.tsx +21 -28
  59. package/src/app/configs/app-navigation.config.ts +11 -5
  60. package/src/app/index.tsx +8 -0
  61. package/src/app/managers/__tests__/viewport-layout.manager.test.ts +59 -0
  62. package/src/app/managers/viewport-layout.manager.ts +39 -0
  63. package/src/app/presenters/app.presenter.ts +25 -2
  64. package/src/app/styles/design-system.css +91 -4
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +86 -0
  66. package/src/features/chat/components/conversation/chat-conversation-content.tsx +47 -17
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +7 -0
  68. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +9 -5
  69. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +5 -5
  70. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +51 -2
  71. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +170 -1
  72. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +23 -1
  73. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +24 -7
  74. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +96 -0
  75. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +181 -0
  76. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +351 -89
  77. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +29 -4
  78. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +28 -3
  79. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -8
  80. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +50 -14
  81. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +77 -6
  82. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +21 -12
  83. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +166 -0
  84. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +19 -11
  85. package/src/features/chat/features/message/components/chat-message-list.container.tsx +38 -64
  86. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +59 -0
  87. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +56 -1
  88. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -2
  89. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +80 -0
  90. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +12 -3
  91. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +60 -0
  92. package/src/features/chat/features/message/utils/chat-message.utils.ts +3 -0
  93. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +39 -0
  94. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  95. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +1 -1
  96. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +3 -1
  97. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +270 -115
  99. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +121 -0
  100. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +75 -20
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +70 -5
  102. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +83 -0
  103. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +79 -1
  104. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +2 -0
  105. package/src/features/chat/managers/chat-thread.manager.ts +9 -3
  106. package/src/features/chat/managers/chat-ui.manager.ts +5 -1
  107. package/src/features/chat/presenters/chat.presenter.ts +2 -1
  108. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
  109. package/src/features/chat/stores/chat-thread.store.ts +6 -64
  110. package/src/features/chat/types/chat-message.types.ts +4 -0
  111. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +33 -26
  112. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +1 -0
  113. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  114. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.tsx +115 -0
  115. package/src/features/marketplace/components/marketplace-detail-doc.tsx +441 -0
  116. package/src/features/marketplace/components/mcp/__tests__/mcp-marketplace-page.test.tsx +3 -3
  117. package/src/features/marketplace/components/mcp/mcp-marketplace-doc.ts +0 -60
  118. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +64 -29
  119. package/src/features/marketplace/hooks/use-marketplace-item-detail.ts +42 -31
  120. package/src/features/marketplace/index.ts +3 -0
  121. package/src/features/marketplace/stores/marketplace-detail-doc.store.ts +38 -0
  122. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +32 -2
  123. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -0
  124. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +8 -3
  125. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -0
  126. package/src/features/settings/pages/appearance-settings-page.tsx +40 -0
  127. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +44 -2
  128. package/src/features/side-dock/components/side-dock.tsx +100 -13
  129. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +10 -0
  130. package/src/features/side-dock/managers/__tests__/side-dock.manager.test.ts +32 -2
  131. package/src/features/side-dock/managers/side-dock.manager.ts +17 -7
  132. package/src/features/side-dock/stores/side-dock.store.ts +9 -0
  133. package/src/features/side-dock/types/side-dock.types.ts +10 -3
  134. package/src/features/side-dock/utils/side-dock-item.utils.ts +15 -2
  135. package/src/index.css +501 -27
  136. package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -1
  137. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +30 -1
  138. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +206 -38
  139. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +34 -11
  140. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -1
  141. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +23 -5
  142. package/src/shared/components/doc-browser/doc-browser.tsx +128 -75
  143. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +9 -1
  144. package/src/shared/components/doc-browser/utils/doc-browser-floating-panel.utils.ts +83 -0
  145. package/src/shared/components/ui/__tests__/dialog.test.tsx +27 -0
  146. package/src/shared/components/ui/actions/icon-action-button.tsx +14 -2
  147. package/src/shared/components/ui/dialog.tsx +2 -2
  148. package/src/shared/components/ui/popover.tsx +1 -1
  149. package/src/shared/components/ui/select.tsx +1 -1
  150. package/src/shared/components/ui/switch.tsx +4 -3
  151. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +54 -0
  152. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +4 -5
  153. package/src/shared/components/ui/tooltip.tsx +1 -1
  154. package/src/shared/lib/api/utils/server-path.utils.ts +27 -1
  155. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -6
  156. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  157. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +11 -0
  158. package/src/shared/lib/i18n/locales/en-US/marketplace.json +5 -0
  159. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -6
  160. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +11 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +5 -0
  163. package/src/shared/lib/session-project/__tests__/workspace-file-breadcrumb.test.ts +14 -0
  164. package/src/shared/lib/session-project/index.ts +1 -1
  165. package/src/shared/lib/session-project/{workspace-file-breadcrumb.ts → workspace-file-breadcrumb.utils.ts} +62 -8
  166. package/src/shared/lib/theme/index.ts +51 -13
  167. package/dist/assets/api-BP_8IaxB.js +0 -13
  168. package/dist/assets/channels-list-page-DEh5Jjqx.js +0 -8
  169. package/dist/assets/chat-page-BRB0N5WW.js +0 -105
  170. package/dist/assets/confirm-dialog-DsivFJ4r.js +0 -5
  171. package/dist/assets/desktop-update-config-B0w9p2ep.js +0 -1
  172. package/dist/assets/doc-browser-BoNJ7-tE.js +0 -1
  173. package/dist/assets/doc-browser-CdJ_d4ik.js +0 -1
  174. package/dist/assets/index-BiE-K8-q.css +0 -1
  175. package/dist/assets/index-D9BAE_L_.js +0 -105
  176. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +0 -40
  177. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +0 -1
  178. package/dist/assets/model-config-page-DotaTUHb.js +0 -1
  179. package/dist/assets/plus-BbZqquJe.js +0 -1
  180. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +0 -1
  181. package/dist/assets/providers-config-page-DW4tQSLN.js +0 -1
  182. package/dist/assets/remote-B8TiUfwW.js +0 -1
  183. package/dist/assets/rotate-cw-DjHDTkCv.js +0 -1
  184. package/dist/assets/runtime-config-page-S6ErRHR8.js +0 -1
  185. package/dist/assets/search-config-page-DGj5am03.js +0 -1
  186. package/dist/assets/select-hoj8dK4K.js +0 -41
  187. package/dist/assets/tag-chip-T8-h-die.js +0 -1
  188. package/dist/assets/use-config-CjRV2hwL.js +0 -1
  189. package/dist/assets/x-D9PXs_xP.js +0 -1
  190. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.ts +0 -40
  191. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +0 -201
  192. package/src/features/marketplace/components/marketplace-detail-doc.ts +0 -142
  193. /package/dist/assets/{config-hints-BkYBc7z0.js → config-hints-fGnUjDe9.js} +0 -0
  194. /package/dist/assets/{middleware-CkFT3R4v.js → middleware-BILWbJE4.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
- import { DocBrowserProvider, useDocBrowser } from '../doc-browser-context';
3
+ import { DocBrowserProvider, useDocBrowser } from '@/shared/components/doc-browser/doc-browser-context';
4
4
  import { RightPanelResourceRouteResolver } from '@/features/right-panel-resources';
5
5
  import { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
6
6
  import { createDefaultDocBrowserState } from '@/shared/components/doc-browser/utils/doc-browser-state.utils';
@@ -53,6 +53,35 @@ async function resetMemoryThenRehydrate(savedState: string | null) {
53
53
  await rehydrateDocBrowserFromStorage();
54
54
  }
55
55
 
56
+ describe('DocBrowserManager layout notifications', () => {
57
+ it('notifies the app layout coordinator after opening the docked browser', () => {
58
+ const onRightPanelOpened = vi.fn();
59
+ const manager = new DocBrowserManager(
60
+ new RightPanelResourceRouteResolver(),
61
+ onRightPanelOpened,
62
+ );
63
+
64
+ manager.open('https://example.com/read', {
65
+ kind: 'content',
66
+ title: 'Example',
67
+ });
68
+
69
+ expect(onRightPanelOpened).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it('notifies the app layout coordinator after changing dock mode', () => {
73
+ const onRightPanelOpened = vi.fn();
74
+ const manager = new DocBrowserManager(
75
+ new RightPanelResourceRouteResolver(),
76
+ onRightPanelOpened,
77
+ );
78
+
79
+ manager.toggleMode();
80
+
81
+ expect(onRightPanelOpened).toHaveBeenCalledTimes(1);
82
+ });
83
+ });
84
+
56
85
  describe('DocBrowserProvider dedupe keys', () => {
57
86
  it('opens different dedupe keys as separate content tabs', () => {
58
87
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
@@ -2,6 +2,7 @@ import { fireEvent, 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 { DocBrowser } from "@/shared/components/doc-browser/doc-browser";
5
+ import { DocBrowserTabStrip } from "@/shared/components/doc-browser/doc-browser-tab-strip";
5
6
  import type {
6
7
  DocBrowserContextValue,
7
8
  DocBrowserTab,
@@ -66,6 +67,15 @@ const { docBrowserState } = vi.hoisted<{
66
67
  },
67
68
  }));
68
69
 
70
+ class TestPointerEvent extends MouseEvent {
71
+ pointerId: number;
72
+
73
+ constructor(type: string, init: PointerEventInit = {}) {
74
+ super(type, init);
75
+ this.pointerId = init.pointerId ?? 1;
76
+ }
77
+ }
78
+
69
79
  vi.mock("@/shared/components/doc-browser/doc-browser-context", async () => {
70
80
  const actual = await vi.importActual(
71
81
  "@/shared/components/doc-browser/doc-browser-context",
@@ -82,49 +92,61 @@ function firePointerEvent(
82
92
  type: string,
83
93
  point: { clientX: number; clientY?: number; pointerId?: number },
84
94
  ) {
85
- const event = new Event(type, { bubbles: true });
86
- Object.defineProperties(event, {
87
- clientX: { value: point.clientX },
88
- clientY: { value: point.clientY ?? 0 },
89
- pointerId: { value: point.pointerId ?? 1 },
95
+ const event = new window.PointerEvent(type, {
96
+ bubbles: true,
97
+ clientX: point.clientX,
98
+ clientY: point.clientY ?? 0,
99
+ pointerId: point.pointerId ?? 1,
90
100
  });
91
101
  fireEvent(target, event);
92
102
  }
93
103
 
104
+ function resetDocBrowserTestState() {
105
+ vi.clearAllMocks();
106
+ docBrowserState.mode = "docked";
107
+ docBrowserState.tabs = [
108
+ {
109
+ id: "docs",
110
+ kind: "docs" as const,
111
+ title: "Docs",
112
+ currentUrl: "https://docs.nextclaw.io/en/guide/getting-started",
113
+ history: ["https://docs.nextclaw.io/en/guide/getting-started"],
114
+ historyIndex: 0,
115
+ navVersion: 0,
116
+ },
117
+ ];
118
+ docBrowserState.activeTabId = "docs";
119
+ docBrowserState.activeHistory = [
120
+ {
121
+ kind: "docs" as const,
122
+ tabId: "docs",
123
+ url: "https://docs.nextclaw.io/en/guide/getting-started",
124
+ },
125
+ ];
126
+ docBrowserState.activeHistoryIndex = 0;
127
+ docBrowserState.currentTab = docBrowserState.tabs[0];
128
+ }
129
+
130
+ function installDocBrowserPointerTestEnvironment() {
131
+ Object.defineProperty(window, "innerWidth", {
132
+ configurable: true,
133
+ value: 1200,
134
+ });
135
+ Object.defineProperty(window, "innerHeight", {
136
+ configurable: true,
137
+ value: 900,
138
+ });
139
+ Object.defineProperty(window, "PointerEvent", {
140
+ configurable: true,
141
+ value: TestPointerEvent,
142
+ });
143
+ HTMLElement.prototype.setPointerCapture = vi.fn();
144
+ }
145
+
94
146
  describe("DocBrowser", () => {
95
147
  beforeEach(() => {
96
- vi.clearAllMocks();
97
- docBrowserState.mode = "docked";
98
- docBrowserState.tabs = [
99
- {
100
- id: "docs",
101
- kind: "docs" as const,
102
- title: "Docs",
103
- currentUrl: "https://docs.nextclaw.io/en/guide/getting-started",
104
- history: ["https://docs.nextclaw.io/en/guide/getting-started"],
105
- historyIndex: 0,
106
- navVersion: 0,
107
- },
108
- ];
109
- docBrowserState.activeTabId = "docs";
110
- docBrowserState.activeHistory = [
111
- {
112
- kind: "docs" as const,
113
- tabId: "docs",
114
- url: "https://docs.nextclaw.io/en/guide/getting-started",
115
- },
116
- ];
117
- docBrowserState.activeHistoryIndex = 0;
118
- docBrowserState.currentTab = docBrowserState.tabs[0];
119
- Object.defineProperty(window, "innerWidth", {
120
- configurable: true,
121
- value: 1200,
122
- });
123
- Object.defineProperty(window, "innerHeight", {
124
- configurable: true,
125
- value: 900,
126
- });
127
- HTMLElement.prototype.setPointerCapture = vi.fn();
148
+ resetDocBrowserTestState();
149
+ installDocBrowserPointerTestEnvironment();
128
150
  });
129
151
 
130
152
  it("uses the shared right panel resize handle in docked mode", () => {
@@ -158,6 +180,7 @@ describe("DocBrowser", () => {
158
180
 
159
181
  const tabStrip = screen.getByTestId("doc-browser-tab-strip");
160
182
  const tabActions = screen.getByTestId("doc-browser-tab-actions");
183
+ const floatButton = screen.getByRole("button", { name: "Float Window" });
161
184
  const newTabButton = screen.getByRole("button", { name: "New Tab" });
162
185
 
163
186
  expect(
@@ -167,8 +190,10 @@ describe("DocBrowser", () => {
167
190
  tabActions.contains(screen.getByRole("button", { name: "Forward" })),
168
191
  ).toBe(true);
169
192
  expect(
170
- tabStrip.contains(screen.getByRole("button", { name: "Float Window" })),
193
+ tabStrip.contains(floatButton),
171
194
  ).toBe(true);
195
+ expect(floatButton.querySelector(".lucide-picture-in-picture2")).toBeTruthy();
196
+ expect(floatButton.querySelector(".lucide-maximize2")).toBeNull();
172
197
  expect(
173
198
  tabStrip.contains(screen.getByRole("button", { name: "Close" })),
174
199
  ).toBe(true);
@@ -372,6 +397,50 @@ describe("DocBrowser", () => {
372
397
  expect(screen.getByText("Help Docs")).toBeTruthy();
373
398
  });
374
399
 
400
+ });
401
+
402
+ describe("DocBrowser floating interactions", () => {
403
+ beforeEach(() => {
404
+ resetDocBrowserTestState();
405
+ installDocBrowserPointerTestEnvironment();
406
+ });
407
+
408
+ it("starts floating drag from the header background without stealing tab actions", () => {
409
+ const onDragStart = vi.fn();
410
+
411
+ render(
412
+ <DocBrowserTabStrip
413
+ tabs={docBrowserState.tabs}
414
+ activeTabId="docs"
415
+ canGoBack={false}
416
+ canGoForward={false}
417
+ isDocked={false}
418
+ isFullscreen={false}
419
+ onGoBack={vi.fn()}
420
+ onGoForward={vi.fn()}
421
+ onOpenNewTab={vi.fn()}
422
+ onSetActiveTab={vi.fn()}
423
+ onCloseTab={vi.fn()}
424
+ onClose={vi.fn()}
425
+ onDragStart={onDragStart}
426
+ onToggleMode={vi.fn()}
427
+ />,
428
+ );
429
+
430
+ fireEvent.pointerDown(screen.getByRole("button", { name: "Docs" }));
431
+ expect(onDragStart).not.toHaveBeenCalled();
432
+
433
+ const tabStrip = screen.getByTestId("doc-browser-tab-strip");
434
+ const headerDragSurface = tabStrip.querySelector(".doc-browser-tab-scrollbar");
435
+ expect(headerDragSurface).toBeInstanceOf(HTMLElement);
436
+ expect((headerDragSurface as HTMLElement).className).toContain("cursor-grab");
437
+ expect(screen.getByRole("button", { name: "Docs" }).parentElement?.className).toContain("cursor-pointer");
438
+
439
+ fireEvent.pointerDown(headerDragSurface as HTMLElement);
440
+
441
+ expect(onDragStart).toHaveBeenCalledTimes(1);
442
+ });
443
+
375
444
  it("keeps the floating panel left edge stable when resizing from the right", () => {
376
445
  docBrowserState.mode = "floating";
377
446
 
@@ -436,4 +505,103 @@ describe("DocBrowser", () => {
436
505
  expect(panel.style.top).toBe("120px");
437
506
  expect(panel.style.height).toBe("560px");
438
507
  });
508
+
509
+ it("resizes the floating panel from the bottom-left corner", () => {
510
+ docBrowserState.mode = "floating";
511
+
512
+ render(<DocBrowser />);
513
+
514
+ firePointerEvent(
515
+ screen.getByTestId("doc-browser-resize-bottom-left"),
516
+ "pointerdown",
517
+ {
518
+ clientX: 680,
519
+ clientY: 680,
520
+ pointerId: 1,
521
+ },
522
+ );
523
+ firePointerEvent(window, "pointermove", {
524
+ clientX: 620,
525
+ clientY: 720,
526
+ pointerId: 1,
527
+ });
528
+
529
+ const panel = screen.getByTestId("doc-browser-panel");
530
+ expect(panel.style.left).toBe("620px");
531
+ expect(panel.style.top).toBe("80px");
532
+ expect(panel.style.width).toBe("540px");
533
+ expect(panel.style.height).toBe("640px");
534
+ });
535
+
536
+ it("resizes the floating panel from the top-right corner", () => {
537
+ docBrowserState.mode = "floating";
538
+
539
+ render(<DocBrowser />);
540
+
541
+ firePointerEvent(
542
+ screen.getByTestId("doc-browser-resize-top-right"),
543
+ "pointerdown",
544
+ {
545
+ clientX: 1160,
546
+ clientY: 80,
547
+ pointerId: 1,
548
+ },
549
+ );
550
+ firePointerEvent(window, "pointermove", {
551
+ clientX: 1120,
552
+ clientY: 120,
553
+ pointerId: 1,
554
+ });
555
+
556
+ const panel = screen.getByTestId("doc-browser-panel");
557
+ expect(panel.style.left).toBe("680px");
558
+ expect(panel.style.top).toBe("120px");
559
+ expect(panel.style.width).toBe("440px");
560
+ expect(panel.style.height).toBe("560px");
561
+ });
562
+ });
563
+
564
+ describe("DocBrowser content tabs", () => {
565
+ beforeEach(() => {
566
+ resetDocBrowserTestState();
567
+ });
568
+
569
+ it("shows browser controls for content iframe tabs", () => {
570
+ const contentTab: DocBrowserTab = {
571
+ id: "local-app",
572
+ kind: "content",
573
+ title: "Local App",
574
+ currentUrl: "http://127.0.0.1:5173/dashboard",
575
+ history: ["http://127.0.0.1:5173/dashboard"],
576
+ historyIndex: 0,
577
+ navVersion: 0,
578
+ };
579
+ docBrowserState.tabs = [contentTab];
580
+ docBrowserState.activeTabId = contentTab.id;
581
+ docBrowserState.currentTab = contentTab;
582
+ docBrowserState.activeHistory = [
583
+ {
584
+ kind: "content",
585
+ tabId: contentTab.id,
586
+ url: contentTab.currentUrl,
587
+ },
588
+ ];
589
+
590
+ render(<DocBrowser />);
591
+
592
+ const addressInput = screen.getByRole("textbox", { name: "Address" }) as HTMLInputElement;
593
+ const iframe = screen.getByTitle("Local App") as HTMLIFrameElement;
594
+ const externalLink = screen.getByRole("link", { name: /Open in Browser/i }) as HTMLAnchorElement;
595
+
596
+ expect(addressInput.value).toBe("http://127.0.0.1:5173/dashboard");
597
+ expect(iframe.getAttribute("src")).toBe(contentTab.currentUrl);
598
+ expect(iframe.getAttribute("sandbox")).toBeNull();
599
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy();
600
+ expect(externalLink.getAttribute("href")).toBe(contentTab.currentUrl);
601
+
602
+ fireEvent.change(addressInput, { target: { value: "localhost:5173/settings" } });
603
+ fireEvent.submit(addressInput.closest("form")!);
604
+
605
+ expect(docBrowserState.navigate).toHaveBeenCalledWith("http://localhost:5173/settings");
606
+ });
439
607
  });
@@ -1,18 +1,22 @@
1
1
  import type { ReactNode, Ref } from 'react';
2
2
  import {
3
3
  ExternalLink,
4
+ RefreshCw,
4
5
  Search,
5
6
  } from 'lucide-react';
6
7
  import {
7
8
  isDocsUrl,
8
9
  type DocBrowserTab,
9
10
  } from './doc-browser-context';
11
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
10
12
  import { t } from '@/shared/lib/i18n';
11
13
 
12
- type DocBrowserDocsToolbarProps = {
13
- isDocsTab: boolean;
14
+ type DocBrowserAddressToolbarProps = {
15
+ isVisible: boolean;
16
+ onRefresh: () => void;
14
17
  onSubmit: (e: React.FormEvent) => void;
15
18
  onUrlInputChange: (value: string) => void;
19
+ placeholder: string;
16
20
  urlInput: string;
17
21
  };
18
22
 
@@ -22,18 +26,20 @@ type DocBrowserFrameContentProps = {
22
26
  customContent: ReactNode;
23
27
  iframeRef: Ref<HTMLIFrameElement>;
24
28
  iframeInstanceId: string;
25
- iframeSandbox: string;
29
+ iframeSandbox?: string;
26
30
  isDragging: boolean;
27
31
  isResizing: boolean;
28
32
  };
29
33
 
30
- export function DocBrowserDocsToolbar({
31
- isDocsTab,
34
+ export function DocBrowserAddressToolbar({
35
+ isVisible,
36
+ onRefresh,
32
37
  onSubmit,
33
38
  onUrlInputChange,
39
+ placeholder,
34
40
  urlInput,
35
- }: DocBrowserDocsToolbarProps) {
36
- if (!isDocsTab) {
41
+ }: DocBrowserAddressToolbarProps) {
42
+ if (!isVisible) {
37
43
  return null;
38
44
  }
39
45
 
@@ -45,10 +51,16 @@ export function DocBrowserDocsToolbar({
45
51
  type="text"
46
52
  value={urlInput}
47
53
  onChange={(e) => onUrlInputChange(e.target.value)}
48
- placeholder={t('docBrowserSearchPlaceholder')}
54
+ aria-label={t('docBrowserAddressLabel')}
55
+ placeholder={placeholder}
49
56
  className="h-8 w-full rounded-lg border border-border bg-background pl-8 pr-3 text-xs text-foreground transition-colors placeholder:text-muted-foreground/55 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/30"
50
57
  />
51
58
  </form>
59
+ <IconActionButton
60
+ icon={<RefreshCw className="h-3.5 w-3.5" />}
61
+ label={t('docBrowserRefresh')}
62
+ onClick={onRefresh}
63
+ />
52
64
  </div>
53
65
  );
54
66
  }
@@ -76,6 +88,7 @@ export function DocBrowserFrameContent({
76
88
  title={currentTab?.title || 'NextClaw Docs'}
77
89
  sandbox={iframeSandbox}
78
90
  allow="clipboard-read; clipboard-write"
91
+ allowFullScreen
79
92
  />
80
93
  )}
81
94
  {(isResizing || isDragging) && (
@@ -85,10 +98,20 @@ export function DocBrowserFrameContent({
85
98
  );
86
99
  }
87
100
 
88
- export function DocBrowserExternalLink({ currentUrl, isDocsTab }: { currentUrl: string; isDocsTab: boolean }) {
89
- if (!isDocsTab || !isDocsUrl(currentUrl)) {
101
+ function canOpenExternalUrl(url: string): boolean {
102
+ try {
103
+ const parsed = new URL(url, window.location.origin);
104
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ export function DocBrowserExternalLink({ currentUrl, isVisible }: { currentUrl: string; isVisible: boolean }) {
111
+ if (!isVisible || !canOpenExternalUrl(currentUrl)) {
90
112
  return null;
91
113
  }
114
+ const label = isDocsUrl(currentUrl) ? t('docBrowserOpenExternal') : t('docBrowserOpenInBrowser');
92
115
 
93
116
  return (
94
117
  <div className="flex items-center justify-between border-t border-border/70 bg-muted/55 px-4 py-2 shrink-0">
@@ -99,7 +122,7 @@ export function DocBrowserExternalLink({ currentUrl, isDocsTab }: { currentUrl:
99
122
  data-doc-external
100
123
  className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
101
124
  >
102
- {t('docBrowserOpenExternal')}
125
+ {label}
103
126
  <ExternalLink className="w-3 h-3" />
104
127
  </a>
105
128
  </div>
@@ -17,7 +17,7 @@ export type DocBrowserIframeMessageParams = {
17
17
  };
18
18
 
19
19
  export type DocBrowserCustomTabRenderer = {
20
- getIframeSandbox?: (tab: DocBrowserTab) => string;
20
+ getIframeSandbox?: (tab: DocBrowserTab) => string | undefined;
21
21
  getTitle?: (tab: DocBrowserTab) => string;
22
22
  onIframeMessage?: (params: DocBrowserIframeMessageParams) => void;
23
23
  renderContent?: (params: DocBrowserCustomTabRenderParams) => ReactNode;
@@ -2,8 +2,8 @@ import type { PointerEvent } from "react";
2
2
  import {
3
3
  ArrowLeft,
4
4
  ArrowRight,
5
- Maximize2,
6
5
  PanelRightOpen,
6
+ PictureInPicture2,
7
7
  Pin,
8
8
  PinOff,
9
9
  Plus,
@@ -37,6 +37,12 @@ type DocBrowserTabStripProps = {
37
37
  onToggleMode: () => void;
38
38
  };
39
39
 
40
+ function shouldBlockHeaderDrag(target: EventTarget | null): boolean {
41
+ return target instanceof Element && Boolean(
42
+ target.closest("button, a, input, textarea, select, [data-compact-tab-item]"),
43
+ );
44
+ }
45
+
40
46
  export function DocBrowserTabStrip({
41
47
  tabs,
42
48
  activeTabId,
@@ -95,7 +101,7 @@ export function DocBrowserTabStrip({
95
101
  ? [
96
102
  {
97
103
  key: "mode",
98
- icon: isDocked ? <Maximize2 className="h-3.5 w-3.5" /> : <PanelRightOpen className="h-3.5 w-3.5" />,
104
+ icon: isDocked ? <PictureInPicture2 className="h-3.5 w-3.5" /> : <PanelRightOpen className="h-3.5 w-3.5" />,
99
105
  label: modeLabel,
100
106
  onClick: onToggleMode,
101
107
  },
@@ -114,16 +120,28 @@ export function DocBrowserTabStrip({
114
120
  "h-11 gap-2 border-border/70 bg-card px-2.5 shrink-0 select-none",
115
121
  isFullscreen && "h-[calc(env(safe-area-inset-top,0px)+2.75rem)] pt-[env(safe-area-inset-top,0px)]",
116
122
  )}
117
- scrollClassName="doc-browser-tab-scrollbar flex h-full items-center gap-1.5"
123
+ scrollClassName={cn(
124
+ "doc-browser-tab-scrollbar flex h-full items-center gap-1.5",
125
+ !isDocked && !isFullscreen && "cursor-grab active:cursor-grabbing",
126
+ )}
118
127
  tabsClassName="items-center gap-1.5"
119
128
  actionsClassName="h-full items-center gap-1"
120
129
  actionButtonClassName="rounded-md p-1.5 hover:text-foreground disabled:opacity-60"
121
- tabBaseClassName="group inline-flex min-w-0 items-center gap-1 h-7 px-1.5 rounded-lg text-xs border max-w-[220px] shrink-0 transition-colors"
130
+ tabBaseClassName="group inline-flex min-w-0 cursor-pointer items-center gap-1 h-7 px-1.5 rounded-lg text-xs border max-w-[220px] shrink-0 transition-colors"
122
131
  activeTabClassName="border-primary/30 bg-primary-50/70 text-foreground shadow-[0_1px_2px_rgba(30,20,10,0.04)]"
123
132
  inactiveTabClassName="border-border bg-muted/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
124
133
  labelClassName="px-1 text-xs font-normal"
125
134
  onPointerDown={!isDocked && !isFullscreen ? onDragStart : undefined}
126
- onScrollPointerDown={(event) => event.stopPropagation()}
135
+ onScrollPointerDown={(event) => {
136
+ if (shouldBlockHeaderDrag(event.target)) {
137
+ event.stopPropagation();
138
+ return;
139
+ }
140
+ if (!isDocked && !isFullscreen) {
141
+ event.stopPropagation();
142
+ onDragStart(event);
143
+ }
144
+ }}
127
145
  />
128
146
  );
129
147
  }