@nextclaw/ui 0.14.4-beta.0 → 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 +119 -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,12 @@
1
- import { Fragment } from "react";
1
+ import { Fragment, useState } from "react";
2
+ import type { ChatFileOpenActionViewModel } from "@nextclaw/agent-chat-ui";
2
3
  import { ChevronRight, FileCode2, FolderTree } from "lucide-react";
3
- import type { WorkspaceFileBreadcrumbViewModel } from "@/shared/lib/session-project";
4
+ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
5
+ import { WorkspaceBreadcrumbBrowser } from "./chat-session-workspace-file-breadcrumb-browser";
6
+ import type {
7
+ WorkspaceFileBreadcrumbSegmentViewModel,
8
+ WorkspaceFileBreadcrumbViewModel,
9
+ } from "@/shared/lib/session-project";
4
10
  import { t } from "@/shared/lib/i18n";
5
11
  import { cn } from "@/shared/lib/utils";
6
12
 
@@ -19,7 +25,69 @@ function WorkspaceBreadcrumbMetaChip({ tone = "neutral", value }: { tone?: "neut
19
25
  );
20
26
  }
21
27
 
22
- export function ChatSessionWorkspaceFileBreadcrumbs({ breadcrumb }: { breadcrumb: WorkspaceFileBreadcrumbViewModel }) {
28
+ function WorkspaceBreadcrumbSegmentButton({
29
+ onFileOpen,
30
+ segment,
31
+ }: {
32
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
33
+ segment: WorkspaceFileBreadcrumbSegmentViewModel;
34
+ }) {
35
+ const [open, setOpen] = useState(false);
36
+ const [browsePath, setBrowsePath] = useState<string | null>(segment.browsePath);
37
+ const handleOpenChange = (nextOpen: boolean) => {
38
+ if (nextOpen) {
39
+ setBrowsePath(segment.browsePath);
40
+ }
41
+ setOpen(nextOpen);
42
+ };
43
+
44
+ return (
45
+ <Popover open={open} onOpenChange={handleOpenChange}>
46
+ <PopoverTrigger asChild>
47
+ <button
48
+ type="button"
49
+ aria-current={segment.isCurrent ? "page" : undefined}
50
+ className={cn(
51
+ "inline-flex h-5 items-center gap-1 rounded-sm px-1 text-[11px] leading-none transition-colors hover:bg-gray-200/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
52
+ segment.kind === "workspace"
53
+ ? "bg-primary/8 text-primary hover:bg-primary/12"
54
+ : segment.isCurrent
55
+ ? "bg-gray-200/70 text-gray-900"
56
+ : "text-gray-500",
57
+ )}
58
+ disabled={!segment.browsePath}
59
+ >
60
+ {segment.kind === "workspace" ? (
61
+ <FolderTree className="h-3 w-3 shrink-0" />
62
+ ) : segment.isCurrent ? (
63
+ <FileCode2 className="h-3 w-3 shrink-0" />
64
+ ) : null}
65
+ <span>{segment.label}</span>
66
+ </button>
67
+ </PopoverTrigger>
68
+ <PopoverContent
69
+ data-testid="workspace-breadcrumb-popover"
70
+ className="w-[22rem] rounded-md p-0"
71
+ align="start"
72
+ >
73
+ <WorkspaceBreadcrumbBrowser
74
+ browsePath={browsePath}
75
+ onBrowsePathChange={setBrowsePath}
76
+ onClose={() => setOpen(false)}
77
+ onFileOpen={onFileOpen}
78
+ />
79
+ </PopoverContent>
80
+ </Popover>
81
+ );
82
+ }
83
+
84
+ export function ChatSessionWorkspaceFileBreadcrumbs({
85
+ breadcrumb,
86
+ onFileOpen,
87
+ }: {
88
+ breadcrumb: WorkspaceFileBreadcrumbViewModel;
89
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
90
+ }) {
23
91
  return (
24
92
  <div
25
93
  data-testid="workspace-file-breadcrumbs"
@@ -33,23 +101,10 @@ export function ChatSessionWorkspaceFileBreadcrumbs({ breadcrumb }: { breadcrumb
33
101
  <div className="flex min-w-0 flex-1 items-center gap-1 pr-1">
34
102
  {breadcrumb.segments.map((segment, index) => (
35
103
  <Fragment key={segment.key}>
36
- <span
37
- className={cn(
38
- "inline-flex h-5 items-center gap-1 rounded-sm px-1 text-[11px] leading-none",
39
- segment.kind === "workspace"
40
- ? "bg-primary/8 text-primary"
41
- : segment.isCurrent
42
- ? "bg-gray-200/70 text-gray-900"
43
- : "text-gray-500",
44
- )}
45
- >
46
- {segment.kind === "workspace" ? (
47
- <FolderTree className="h-3 w-3 shrink-0" />
48
- ) : segment.isCurrent ? (
49
- <FileCode2 className="h-3 w-3 shrink-0" />
50
- ) : null}
51
- <span>{segment.label}</span>
52
- </span>
104
+ <WorkspaceBreadcrumbSegmentButton
105
+ segment={segment}
106
+ onFileOpen={onFileOpen}
107
+ />
53
108
  {index < breadcrumb.segments.length - 1 ? (
54
109
  <ChevronRight className="h-3 w-3 shrink-0 text-gray-300" />
55
110
  ) : null}
@@ -1,9 +1,14 @@
1
1
  import { useMemo } from "react";
2
- import type { ChatFileOpenActionViewModel, ChatFileOperationBlockViewModel } from "@nextclaw/agent-chat-ui";
2
+ import type {
3
+ ChatFileOpenActionViewModel,
4
+ ChatFileOperationBlockViewModel,
5
+ ChatFilePreviewViewer,
6
+ } from "@nextclaw/agent-chat-ui";
3
7
  import { ChatMessageMarkdown, FileOperationCodeSurface } from "@nextclaw/agent-chat-ui";
4
8
  import type { ChatWorkspaceFileTab } from "@/features/chat/stores/chat-thread.store";
5
9
  import { ChatSessionWorkspaceFileBreadcrumbs } from "./chat-session-workspace-file-breadcrumbs";
6
10
  import { useServerPathRead } from "@/shared/hooks/use-server-path-read";
11
+ import { buildServerPathContentUrl } from "@/shared/lib/api";
7
12
  import { buildLineDiff, buildPreviewLines } from "@/features/chat/features/message/utils/file-operation/line-builder.utils";
8
13
  import { t } from "@/shared/lib/i18n";
9
14
  import { buildWorkspaceFileBreadcrumb } from "@/shared/lib/session-project";
@@ -19,12 +24,26 @@ function inferPreviewKind(params: {
19
24
  return /\.mdx?$/i.test(params.path) ? "markdown" : "text";
20
25
  }
21
26
 
27
+ function isHtmlFilePath(path: string): boolean {
28
+ return /\.html?$/i.test(path);
29
+ }
30
+
31
+ function resolveFilePreviewViewer(params: {
32
+ path: string;
33
+ viewer?: ChatFilePreviewViewer | null;
34
+ }): "source" | "rendered" {
35
+ return params.viewer === "rendered" && isHtmlFilePath(params.path)
36
+ ? "rendered"
37
+ : "source";
38
+ }
39
+
22
40
  function buildPreviewBlock(params: {
23
41
  path: string;
24
42
  text: string;
43
+ languageHint?: string | null;
25
44
  line?: number | null;
26
45
  }): ChatFileOperationBlockViewModel {
27
- const { line, path, text } = params;
46
+ const { languageHint, line, path, text } = params;
28
47
  const startLine = line ?? 1;
29
48
  return {
30
49
  key: `preview:${path}`,
@@ -37,6 +56,7 @@ function buildPreviewBlock(params: {
37
56
  newStartLine: startLine,
38
57
  }),
39
58
  rawText: text,
59
+ languageHint: languageHint ?? null,
40
60
  oldStartLine: startLine,
41
61
  newStartLine: startLine,
42
62
  };
@@ -130,18 +150,38 @@ function WorkspaceCodeSurface({
130
150
  return <div className="h-full overflow-auto custom-scrollbar bg-white"><FileOperationCodeSurface block={block} layout="workspace" /></div>;
131
151
  }
132
152
 
153
+ function WorkspaceHtmlRenderedPreview({
154
+ src,
155
+ }: {
156
+ src: string;
157
+ }) {
158
+ return (
159
+ <iframe
160
+ allowFullScreen
161
+ className="h-full w-full border-0 bg-white"
162
+ data-testid="workspace-html-preview"
163
+ src={src}
164
+ title={t("chatWorkspaceHtmlPreviewTitle")}
165
+ />
166
+ );
167
+ }
168
+
133
169
  function WorkspacePreviewBody({
134
170
  onFileOpen,
135
171
  previewBlock,
136
172
  previewKind,
137
173
  previewQuery,
138
174
  previewText,
175
+ previewUrl,
176
+ previewViewer,
139
177
  }: {
140
178
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
141
179
  previewBlock: ChatFileOperationBlockViewModel | null;
142
180
  previewKind: "text" | "markdown" | "binary";
143
181
  previewQuery: ReturnType<typeof useServerPathRead>;
144
182
  previewText: string | null;
183
+ previewUrl: string | null;
184
+ previewViewer: "source" | "rendered";
145
185
  }) {
146
186
  if (previewQuery.isLoading && !previewBlock) {
147
187
  return <WorkspaceFilePreviewStatus text={t("chatWorkspaceLoadingFile")} />;
@@ -166,6 +206,10 @@ function WorkspacePreviewBody({
166
206
  );
167
207
  }
168
208
 
209
+ if (previewViewer === "rendered" && previewUrl) {
210
+ return <WorkspaceHtmlRenderedPreview src={previewUrl} />;
211
+ }
212
+
169
213
  if (previewKind === "markdown" && previewText) {
170
214
  return (
171
215
  <div className="h-full overflow-auto custom-scrollbar px-5 py-4">
@@ -218,6 +262,15 @@ export function ChatSessionWorkspaceFilePreview({
218
262
  path: previewQuery.data?.resolvedPath ?? file.path,
219
263
  serverKind: previewQuery.data?.kind,
220
264
  });
265
+ const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
266
+ const previewViewer = resolveFilePreviewViewer({
267
+ path: resolvedPath,
268
+ viewer: file.previewViewer,
269
+ });
270
+ const previewUrl =
271
+ previewViewer === "rendered" && previewQuery.data?.resolvedPath
272
+ ? buildServerPathContentUrl(previewQuery.data.resolvedPath)
273
+ : null;
221
274
  const previewBlock = useMemo(() => {
222
275
  if (!isPreviewMode || !previewText) {
223
276
  return null;
@@ -225,10 +278,17 @@ export function ChatSessionWorkspaceFilePreview({
225
278
  return buildPreviewBlock({
226
279
  path: previewQuery.data?.resolvedPath ?? file.path,
227
280
  text: previewText,
281
+ languageHint: previewQuery.data?.languageHint ?? null,
228
282
  line: file.line,
229
283
  });
230
- }, [file.line, file.path, isPreviewMode, previewQuery.data?.resolvedPath, previewText]);
231
- const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
284
+ }, [
285
+ file.line,
286
+ file.path,
287
+ isPreviewMode,
288
+ previewQuery.data?.languageHint,
289
+ previewQuery.data?.resolvedPath,
290
+ previewText,
291
+ ]);
232
292
  const isTruncated = Boolean(previewQuery.data?.truncated);
233
293
  const breadcrumbBasePath = sessionProjectRoot ?? sessionWorkingDir;
234
294
  const breadcrumb = useMemo(
@@ -245,7 +305,10 @@ export function ChatSessionWorkspaceFilePreview({
245
305
 
246
306
  return (
247
307
  <div className="flex h-full min-h-0 flex-col bg-white">
248
- <ChatSessionWorkspaceFileBreadcrumbs breadcrumb={breadcrumb} />
308
+ <ChatSessionWorkspaceFileBreadcrumbs
309
+ breadcrumb={breadcrumb}
310
+ onFileOpen={onFileOpen}
311
+ />
249
312
 
250
313
  <div className="flex-1 min-h-0 overflow-hidden">
251
314
  {file.viewMode === "diff" ? (
@@ -257,6 +320,8 @@ export function ChatSessionWorkspaceFilePreview({
257
320
  previewKind={previewKind}
258
321
  previewQuery={previewQuery}
259
322
  previewText={previewText}
323
+ previewUrl={previewUrl}
324
+ previewViewer={previewViewer}
260
325
  />
261
326
  )}
262
327
  </div>
@@ -0,0 +1,83 @@
1
+ import type { ChatFilePreviewViewer } from '@nextclaw/agent-chat-ui';
2
+ import type { ChatWorkspaceFileTab } from '@/features/chat/stores/chat-thread.store';
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return !!value && typeof value === 'object';
6
+ }
7
+
8
+ function normalizeOptionalString(value: unknown): string | null {
9
+ return typeof value === 'string' && value.trim().length > 0
10
+ ? value.trim()
11
+ : null;
12
+ }
13
+
14
+ function normalizeOptionalNumber(value: unknown): number | null {
15
+ return typeof value === 'number' && Number.isFinite(value)
16
+ ? value
17
+ : null;
18
+ }
19
+
20
+ function normalizeOptionalText(value: unknown): string | null {
21
+ return typeof value === 'string' ? value : null;
22
+ }
23
+
24
+ function normalizeFilePreviewViewer(value: unknown): ChatFilePreviewViewer | null {
25
+ return value === 'auto' || value === 'source' || value === 'rendered'
26
+ ? value
27
+ : null;
28
+ }
29
+
30
+ export function normalizePersistedWorkspaceFileTab(
31
+ value: unknown,
32
+ ): ChatWorkspaceFileTab | null {
33
+ if (
34
+ !isRecord(value)
35
+ || typeof value.key !== 'string'
36
+ || typeof value.path !== 'string'
37
+ || (value.viewMode !== 'preview' && value.viewMode !== 'diff')
38
+ ) {
39
+ return null;
40
+ }
41
+ const key = value.key.trim();
42
+ const path = value.path.trim();
43
+ if (!key || !path) {
44
+ return null;
45
+ }
46
+ return {
47
+ key,
48
+ parentSessionKey: normalizeOptionalString(value.parentSessionKey),
49
+ path,
50
+ label: normalizeOptionalString(value.label),
51
+ viewMode: value.viewMode,
52
+ previewViewer: normalizeFilePreviewViewer(value.previewViewer),
53
+ line: normalizeOptionalNumber(value.line),
54
+ column: normalizeOptionalNumber(value.column),
55
+ rawText: normalizeOptionalText(value.rawText),
56
+ beforeText: normalizeOptionalText(value.beforeText),
57
+ afterText: normalizeOptionalText(value.afterText),
58
+ patchText: normalizeOptionalText(value.patchText),
59
+ oldStartLine: normalizeOptionalNumber(value.oldStartLine),
60
+ newStartLine: normalizeOptionalNumber(value.newStartLine),
61
+ };
62
+ }
63
+
64
+ export function toPersistedWorkspaceFileTab(
65
+ tab: ChatWorkspaceFileTab,
66
+ ): ChatWorkspaceFileTab {
67
+ return {
68
+ key: tab.key,
69
+ parentSessionKey: tab.parentSessionKey,
70
+ path: tab.path,
71
+ label: tab.label,
72
+ viewMode: tab.viewMode,
73
+ previewViewer: tab.previewViewer,
74
+ line: tab.line,
75
+ column: tab.column,
76
+ rawText: tab.rawText,
77
+ beforeText: tab.beforeText,
78
+ afterText: tab.afterText,
79
+ patchText: tab.patchText,
80
+ oldStartLine: tab.oldStartLine,
81
+ newStartLine: tab.newStartLine,
82
+ };
83
+ }
@@ -155,6 +155,40 @@ describe('ChatThreadManager', () => {
155
155
  expect(uiManager.goToSession).not.toHaveBeenCalled();
156
156
  });
157
157
 
158
+ it('notifies the app layout coordinator after opening the workspace panel', () => {
159
+ const onWorkspacePanelOpened = vi.fn();
160
+ const manager = new ChatThreadManager(
161
+ createUiManager(),
162
+ {} as ConstructorParameters<typeof ChatThreadManager>[1],
163
+ onWorkspacePanelOpened,
164
+ );
165
+
166
+ manager.openFilePreview({
167
+ path: 'README.md',
168
+ label: 'README.md',
169
+ viewMode: 'preview',
170
+ });
171
+
172
+ expect(onWorkspacePanelOpened).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ it('does not notify the app layout coordinator when no workspace panel opens', () => {
176
+ const onWorkspacePanelOpened = vi.fn();
177
+ const manager = new ChatThreadManager(
178
+ createUiManager(),
179
+ {} as ConstructorParameters<typeof ChatThreadManager>[1],
180
+ onWorkspacePanelOpened,
181
+ );
182
+
183
+ manager.openFilePreview({
184
+ path: ' ',
185
+ label: 'README.md',
186
+ viewMode: 'preview',
187
+ });
188
+
189
+ expect(onWorkspacePanelOpened).not.toHaveBeenCalled();
190
+ });
191
+
158
192
  it('opens a side chat draft beside the selected parent session', () => {
159
193
  const uiManager = createUiManager({
160
194
  goToSession: vi.fn(),
@@ -293,6 +327,47 @@ describe('ChatThreadManager', () => {
293
327
  ]),
294
328
  );
295
329
  });
330
+
331
+ });
332
+
333
+ describe('ChatThreadManager workspace file tabs', () => {
334
+ it('keeps source and rendered previews for the same file as separate workspace tabs', () => {
335
+ const manager = new ChatThreadManager(
336
+ createUiManager(),
337
+ {} as ConstructorParameters<typeof ChatThreadManager>[1],
338
+ );
339
+
340
+ manager.openFilePreview({
341
+ path: 'demo.html',
342
+ label: 'demo.html',
343
+ viewMode: 'preview',
344
+ previewViewer: 'source',
345
+ });
346
+ manager.openFilePreview({
347
+ path: 'demo.html',
348
+ label: 'demo.html',
349
+ viewMode: 'preview',
350
+ previewViewer: 'rendered',
351
+ });
352
+
353
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
354
+ activeWorkspaceFileKey: 'parent-session-1::preview:rendered::demo.html',
355
+ workspaceFileTabs: [
356
+ {
357
+ key: 'parent-session-1::preview:rendered::demo.html',
358
+ path: 'demo.html',
359
+ viewMode: 'preview',
360
+ previewViewer: 'rendered',
361
+ },
362
+ {
363
+ key: 'parent-session-1::preview::demo.html',
364
+ path: 'demo.html',
365
+ viewMode: 'preview',
366
+ previewViewer: 'source',
367
+ },
368
+ ],
369
+ });
370
+ });
296
371
  });
297
372
 
298
373
  describe('ChatThreadManager workspace navigation', () => {
@@ -513,6 +588,7 @@ describe('ChatThreadManager showContent', () => {
513
588
  payload: {
514
589
  path: 'docs/example.md',
515
590
  line: 5,
591
+ viewer: 'rendered',
516
592
  },
517
593
  },
518
594
  title: 'Example',
@@ -522,12 +598,14 @@ describe('ChatThreadManager showContent', () => {
522
598
 
523
599
  expect(useChatThreadStore.getState().snapshot).toMatchObject({
524
600
  activeWorkspacePanelKind: 'file',
525
- activeWorkspaceFileKey: 'parent-session-1::preview::docs/example.md',
601
+ activeWorkspaceFileKey: 'parent-session-1::preview:rendered::docs/example.md',
526
602
  workspaceFileTabs: [
527
603
  expect.objectContaining({
604
+ key: 'parent-session-1::preview:rendered::docs/example.md',
528
605
  path: 'docs/example.md',
529
606
  label: 'Example',
530
607
  line: 5,
608
+ previewViewer: 'rendered',
531
609
  viewMode: 'preview',
532
610
  }),
533
611
  ],
@@ -68,6 +68,8 @@ describe('ChatUiManager content display', () => {
68
68
  });
69
69
 
70
70
  expect(docBrowserManager.open).toHaveBeenCalledWith('https://example.com/read', {
71
+ dedupeKey: 'browser:https://example.com/read',
72
+ kind: 'content',
71
73
  title: 'Example URL',
72
74
  });
73
75
  });
@@ -44,6 +44,7 @@ export class ChatThreadManager {
44
44
  constructor(
45
45
  private uiManager: ChatUiManager,
46
46
  private sessionListManager: ChatSessionListManager,
47
+ private readonly onWorkspacePanelOpened?: () => void,
47
48
  ) {}
48
49
 
49
50
  private hasSnapshotChanges = (patch: Partial<ChatThreadSnapshot>): boolean => {
@@ -110,14 +111,14 @@ export class ChatThreadManager {
110
111
  return null;
111
112
  }
112
113
  const normalizedParentSessionKey = parentSessionKey?.trim() || null;
113
- const key =
114
- `${normalizedParentSessionKey ?? 'draft'}::${action.viewMode}::${normalizedPath}`;
114
+ const tabViewIdentity = action.viewMode === 'preview' && action.previewViewer === 'rendered' ? 'preview:rendered' : action.viewMode;
115
115
  return {
116
- key,
116
+ key: `${normalizedParentSessionKey ?? 'draft'}::${tabViewIdentity}::${normalizedPath}`,
117
117
  parentSessionKey: normalizedParentSessionKey,
118
118
  path: normalizedPath,
119
119
  label: action.label?.trim() || null,
120
120
  viewMode: action.viewMode,
121
+ previewViewer: action.previewViewer ?? null,
121
122
  line: action.line ?? null,
122
123
  column: action.column ?? null,
123
124
  rawText: action.rawText ?? null,
@@ -265,6 +266,7 @@ export class ChatThreadManager {
265
266
  useChatThreadStore.getState().setSnapshot(patch);
266
267
  }
267
268
  this.ensureWorkspaceParentRoute(parentSessionKey);
269
+ this.onWorkspacePanelOpened?.();
268
270
  };
269
271
 
270
272
  openSideChatDraft = (parentSessionKey?: string | null) => {
@@ -285,6 +287,7 @@ export class ChatThreadManager {
285
287
  key: activeSideChatDraft.draftKey,
286
288
  });
287
289
  this.ensureWorkspaceParentRoute(resolvedParentSessionKey);
290
+ this.onWorkspacePanelOpened?.();
288
291
  };
289
292
 
290
293
  materializeSideChatDraft = (params: {
@@ -322,6 +325,7 @@ export class ChatThreadManager {
322
325
  key: nextTab.key,
323
326
  });
324
327
  this.ensureWorkspaceParentRoute(parentSessionKey);
328
+ this.onWorkspacePanelOpened?.();
325
329
  };
326
330
 
327
331
  private openSessionFromToolAction = (action: ChatToolActionViewModel) => {
@@ -384,6 +388,7 @@ export class ChatThreadManager {
384
388
  path: request.target.payload.path,
385
389
  label: request.title,
386
390
  viewMode: 'preview',
391
+ previewViewer: request.target.payload.viewer,
387
392
  line: request.target.payload.line,
388
393
  column: request.target.payload.column,
389
394
  });
@@ -504,6 +509,7 @@ export class ChatThreadManager {
504
509
  kind: 'cron',
505
510
  });
506
511
  this.ensureWorkspaceParentRoute(parentSessionKey);
512
+ this.onWorkspacePanelOpened?.();
507
513
  };
508
514
 
509
515
  goBackWorkspacePanel = () => {
@@ -89,7 +89,11 @@ export class ChatUiManager {
89
89
  showContent = async (params: { target: ChatUiDisplayContentTarget; title?: string }) => {
90
90
  const { target, title } = params;
91
91
  if (target.type === 'url') {
92
- this.docBrowserManager.open(target.payload.url, { title });
92
+ this.docBrowserManager.open(target.payload.url, {
93
+ dedupeKey: `browser:${target.payload.url}`,
94
+ kind: 'content',
95
+ title,
96
+ });
93
97
  return;
94
98
  }
95
99
  const { appId } = target.payload;
@@ -16,7 +16,8 @@ export class ChatPresenter {
16
16
  this.chatQueryManager = new ChatQueryManager();
17
17
  this.chatThreadManager = new ChatThreadManager(
18
18
  this.chatUiManager,
19
- this.chatSessionListManager
19
+ this.chatSessionListManager,
20
+ appPresenter.notifyRightPanelOpened,
20
21
  );
21
22
  }
22
23
  }
@@ -54,6 +54,7 @@ describe('chat thread workspace panel persistence', () => {
54
54
  path: 'README.md',
55
55
  label: 'README.md',
56
56
  viewMode: 'preview',
57
+ previewViewer: 'rendered',
57
58
  rawText: '# Hello',
58
59
  fullLines: [],
59
60
  },
@@ -83,6 +84,7 @@ describe('chat thread workspace panel persistence', () => {
83
84
  expect(persisted.state.snapshot.sessionKey).toBeUndefined();
84
85
  expect(persisted.state.snapshot.isSending).toBeUndefined();
85
86
  expect(persisted.state.snapshot.workspaceFileTabs[0].rawText).toBe('# Hello');
87
+ expect(persisted.state.snapshot.workspaceFileTabs[0].previewViewer).toBe('rendered');
86
88
  expect(persisted.state.snapshot.workspaceFileTabs[0].fullLines).toBeUndefined();
87
89
  });
88
90
 
@@ -138,6 +140,7 @@ describe('chat thread workspace panel persistence', () => {
138
140
  path: 'README.md',
139
141
  label: 'README.md',
140
142
  viewMode: 'preview',
143
+ previewViewer: 'rendered',
141
144
  rawText: '# Hello',
142
145
  },
143
146
  {
@@ -171,6 +174,7 @@ describe('chat thread workspace panel persistence', () => {
171
174
  path: 'README.md',
172
175
  label: 'README.md',
173
176
  viewMode: 'preview',
177
+ previewViewer: 'rendered',
174
178
  rawText: '# Hello',
175
179
  },
176
180
  ],