@nextclaw/ui 0.15.3 → 0.15.4

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 (184) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assets/aiden0z-pptx-renderer.es-Bd99vut2.js +94 -0
  3. package/dist/assets/api-BOXOrpcJ.js +13 -0
  4. package/dist/assets/app-presenter-provider-CkHLbvZQ.js +106 -0
  5. package/dist/assets/app-presenter-provider-D3BuuOyZ.css +1 -0
  6. package/dist/assets/appearance-settings-page-fLIJbx1C.js +1 -0
  7. package/dist/assets/{book-open-C3mJGwiy.js → book-open-DWUjS6aW.js} +1 -1
  8. package/dist/assets/channels-list-page-CvCTJraa.js +8 -0
  9. package/dist/assets/chat-page-B42UUIFR.js +1 -0
  10. package/dist/assets/chunk-DseTPa7n.js +1 -0
  11. package/dist/assets/config-split-page-DBgt9S8b.js +1 -0
  12. package/dist/assets/confirm-dialog-GSXIvoRU.js +5 -0
  13. package/dist/assets/desktop-update-config-7bmNeWIC.js +1 -0
  14. package/dist/assets/dist-DC7f4TJH.js +41 -0
  15. package/dist/assets/doc-browser-CgdoLUT92.js +1 -0
  16. package/dist/assets/doc-browser-D0monHjG.js +1 -0
  17. package/dist/assets/doc-browser-context-CGwEOhBy.js +1 -0
  18. package/dist/assets/docx-preview-eFH9DRsS.js +20 -0
  19. package/dist/assets/download-DiP9zx-J.js +1 -0
  20. package/dist/assets/{external-link-CU0kzzIP.js → external-link-C6ZeSPef.js} +1 -1
  21. package/dist/assets/eye-suvKmtwn.js +1 -0
  22. package/dist/assets/folder-CMELbQGG.js +1 -0
  23. package/dist/assets/{form-actions-BvCp_0lS.js → form-actions-CUAzpNe_.js} +1 -1
  24. package/dist/assets/index-BFeNDQ4R.css +1 -0
  25. package/dist/assets/index-D_6U-M94.js +2 -0
  26. package/dist/assets/jszip.min-Das2IAnb.js +2 -0
  27. package/dist/assets/loader-circle-tYHStefV.js +1 -0
  28. package/dist/assets/logo-badge-Cu5UGH_4.js +1 -0
  29. package/dist/assets/mcp-marketplace-page-BUzHDwW6.js +9 -0
  30. package/dist/assets/mcp-marketplace-page-ohUAdfqJ.js +1 -0
  31. package/dist/assets/model-config-page-CWP6pyBm.js +1 -0
  32. package/dist/assets/plus-tywizkeY.js +1 -0
  33. package/dist/assets/popover-DImguRok.js +1 -0
  34. package/dist/assets/provider-models-C_yOh6DE.js +1 -0
  35. package/dist/assets/provider-scoped-model-input-CE7p4SbJ.js +1 -0
  36. package/dist/assets/providers-config-page-BKPo8SjH.js +1 -0
  37. package/dist/assets/react-5sjAT_6d.js +8 -0
  38. package/dist/assets/{refresh-cw-CILd1vAP.js → refresh-cw-CHLBzWWj.js} +1 -1
  39. package/dist/assets/remote-DXJryUxB.js +1 -0
  40. package/dist/assets/rotate-cw-BefO5brU.js +1 -0
  41. package/dist/assets/runtime-config-page-1Axv4QGu.js +1 -0
  42. package/dist/assets/{save-ByJqcPTP.js → save-BABzyrjB.js} +1 -1
  43. package/dist/assets/{search-DVIV6pfD.js → search-BTqxG3ZG.js} +1 -1
  44. package/dist/assets/search-config-page-BkKKmxmS.js +1 -0
  45. package/dist/assets/secrets-config-page-CWJMyu-k.js +3 -0
  46. package/dist/assets/security-config-DeQUIXfi.js +1 -0
  47. package/dist/assets/select-TYZDBNmq.js +1 -0
  48. package/dist/assets/setting-row-DR23Z_5I.js +1 -0
  49. package/dist/assets/{settings-2-CH5shGg_.js → settings-2-D8WWcBnw.js} +1 -1
  50. package/dist/assets/{skeleton-DeX1FKw7.js → skeleton-CloHeg0S.js} +1 -1
  51. package/dist/assets/tabs-CX-ckWoo.js +1 -0
  52. package/dist/assets/tag-chip-mCc23Rhb.js +1 -0
  53. package/dist/assets/trash-2-SAW1Z4Po.js +1 -0
  54. package/dist/assets/use-config-BucdaK23.js +1 -0
  55. package/dist/assets/use-confirm-dialog-C95F6sT1.js +1 -0
  56. package/dist/assets/use-viewport-layout-C6m3Nte7.js +1 -0
  57. package/dist/assets/x-DtCPHwCu.js +1 -0
  58. package/dist/assets/xlsx-B6zVt1RK.js +64 -0
  59. package/dist/index.html +34 -28
  60. package/package.json +11 -6
  61. package/src/features/chat/components/__tests__/chat-popover-content.test.tsx +41 -0
  62. package/src/features/chat/components/chat-popover-content.tsx +26 -0
  63. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +13 -0
  64. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +21 -11
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-header.test.tsx +2 -2
  66. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +28 -1
  67. package/src/features/chat/components/conversation/chat-conversation-content.tsx +5 -4
  68. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +9 -18
  69. package/src/features/chat/components/conversation/chat-conversation-track.tsx +35 -0
  70. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +1 -0
  71. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +7 -7
  72. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +3 -14
  73. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +20 -1
  74. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +69 -54
  75. package/src/features/chat/features/message/components/chat-message-list.container.tsx +16 -1
  76. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +30 -0
  77. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +37 -0
  78. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +4 -3
  79. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +3 -8
  80. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +65 -20
  81. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +26 -28
  82. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -3
  83. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +3 -3
  84. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +3 -3
  85. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +3 -3
  86. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +403 -106
  87. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +113 -2
  88. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -0
  89. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +197 -44
  90. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +9 -3
  91. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +7 -5
  92. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +57 -177
  93. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +239 -1
  94. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +56 -6
  95. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +29 -7
  96. package/src/features/chat/features/workspace/components/file-content-preview/use-workspace-file-buffer.ts +42 -0
  97. package/src/features/chat/features/workspace/components/file-content-preview/workspace-document-preview-state.tsx +18 -0
  98. package/src/features/chat/features/workspace/components/file-content-preview/workspace-docx-preview.css +89 -0
  99. package/src/features/chat/features/workspace/components/file-content-preview/workspace-docx-preview.tsx +87 -0
  100. package/src/features/chat/features/workspace/components/file-content-preview/workspace-presentation-preview.tsx +82 -0
  101. package/src/features/chat/features/workspace/components/file-content-preview/workspace-spreadsheet-preview.tsx +180 -0
  102. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +191 -0
  103. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +1 -4
  104. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +53 -10
  105. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +68 -2
  106. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +17 -0
  107. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +63 -0
  108. package/src/features/chat/features/workspace/utils/chat-workspace-file-viewer.utils.ts +49 -0
  109. package/src/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils.ts +13 -0
  110. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +120 -38
  111. package/src/features/chat/index.ts +1 -0
  112. package/src/features/chat/managers/__tests__/chat-thread-workspace.manager.test.ts +76 -0
  113. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +44 -60
  114. package/src/features/chat/managers/chat-thread.manager.ts +123 -126
  115. package/src/features/chat/pages/ncp-chat-page.tsx +1 -2
  116. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +78 -1
  117. package/src/features/chat/stores/chat-message-layout.store.test.ts +37 -0
  118. package/src/features/chat/stores/chat-message-layout.store.ts +41 -0
  119. package/src/features/chat/stores/chat-thread.store.ts +70 -13
  120. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -8
  121. package/src/features/settings/pages/appearance-settings-page.tsx +92 -13
  122. package/src/index.css +36 -2
  123. package/src/shared/components/__tests__/file-type-icon.test.tsx +61 -0
  124. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +19 -0
  125. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +16 -0
  126. package/src/shared/components/doc-browser/doc-browser-context.tsx +1 -0
  127. package/src/shared/components/doc-browser/doc-browser.tsx +11 -3
  128. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +5 -0
  129. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +6 -1
  130. package/src/shared/components/doc-browser/types/doc-browser.types.ts +2 -0
  131. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +11 -0
  132. package/src/shared/components/file-type-icon.tsx +272 -0
  133. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +22 -0
  134. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +17 -2
  135. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +38 -0
  136. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +82 -2
  137. package/src/shared/lib/api/__tests__/server-path.utils.test.ts +28 -0
  138. package/src/shared/lib/api/utils/server-path.utils.ts +19 -20
  139. package/src/shared/lib/i18n/locales/en-US/chat.json +17 -1
  140. package/src/shared/lib/i18n/locales/en-US/core.json +6 -0
  141. package/src/shared/lib/i18n/locales/zh-CN/chat.json +17 -1
  142. package/src/shared/lib/i18n/locales/zh-CN/core.json +6 -0
  143. package/dist/assets/api-iNBpppzj.js +0 -13
  144. package/dist/assets/appearance-settings-page-DMffQFR3.js +0 -1
  145. package/dist/assets/channels-list-page-CCtp0BRS.js +0 -8
  146. package/dist/assets/chat-page-H5a0dWvO.js +0 -1
  147. package/dist/assets/config-split-page-CrOJgB-Y.js +0 -1
  148. package/dist/assets/confirm-dialog-Chv50USy.js +0 -5
  149. package/dist/assets/desktop-update-config-KzTuXRf_.js +0 -1
  150. package/dist/assets/dist-BunzmC4B.js +0 -1
  151. package/dist/assets/dist-ChmGwvtK.js +0 -1
  152. package/dist/assets/doc-browser-DNRL4Zvd.js +0 -1
  153. package/dist/assets/doc-browser-Dg0pmkOg.js +0 -1
  154. package/dist/assets/doc-browser-context-BHY-0FSD.js +0 -1
  155. package/dist/assets/es2015-DQZ5JWwK.js +0 -41
  156. package/dist/assets/i18n-provider-Cu8XyZb_.js +0 -1
  157. package/dist/assets/index-CzfBdvK1.css +0 -1
  158. package/dist/assets/index-DGqHne1Q.js +0 -104
  159. package/dist/assets/loader-circle-CDSFuzmk.js +0 -1
  160. package/dist/assets/mcp-marketplace-page-BeEQK0DL.js +0 -9
  161. package/dist/assets/mcp-marketplace-page-D6idOTLp.js +0 -1
  162. package/dist/assets/model-config-page-bNNo-XyG.js +0 -1
  163. package/dist/assets/plus-D4DsdLN-.js +0 -1
  164. package/dist/assets/provider-scoped-model-input-IlgQCsoT.js +0 -1
  165. package/dist/assets/providers-config-page-wLo6CAJw.js +0 -1
  166. package/dist/assets/react-BPIsQIlO.js +0 -8
  167. package/dist/assets/remote-uKEYp7AR.js +0 -1
  168. package/dist/assets/rotate-cw-wPFPg1kH.js +0 -1
  169. package/dist/assets/runtime-config-page-DDn1sP4G.js +0 -1
  170. package/dist/assets/search-config-page-B-Cf4iiB.js +0 -1
  171. package/dist/assets/secrets-config-page-DPKsY8Iw.js +0 -3
  172. package/dist/assets/security-config-e9SSBlKr.js +0 -1
  173. package/dist/assets/select-BdgczSNw.js +0 -1
  174. package/dist/assets/setting-row-BkTVUTKx.js +0 -1
  175. package/dist/assets/side-dock-BsBvcJ9i.js +0 -3
  176. package/dist/assets/switch-Cv8Ekb3x.js +0 -1
  177. package/dist/assets/tag-chip-RB9uKcoC.js +0 -1
  178. package/dist/assets/tooltip-oQlEN-l6.js +0 -1
  179. package/dist/assets/use-config-l3W7y8Qz.js +0 -1
  180. package/dist/assets/use-confirm-dialog-BAxaVOJI.js +0 -1
  181. package/dist/assets/x-BBMffEN0.js +0 -1
  182. /package/dist/assets/{config-hints-C_iP1zl_.js → config-hints-fDrYfl0l.js} +0 -0
  183. /package/dist/assets/{host-capabilities-9bB_998Z.js → host-capabilities-zZvY9U63.js} +0 -0
  184. /package/dist/assets/{middleware-BILWbJE4.js → middleware-BLMHv9YF.js} +0 -0
@@ -0,0 +1,180 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useWorkspaceFileBuffer } from "./use-workspace-file-buffer";
3
+ import { WorkspaceDocumentPreviewState } from "./workspace-document-preview-state";
4
+ import { t } from "@/shared/lib/i18n";
5
+ import { cn } from "@/shared/lib/utils";
6
+
7
+ const MAX_PREVIEW_ROWS = 500;
8
+ const MAX_PREVIEW_COLUMNS = 100;
9
+
10
+ type SpreadsheetSheet = {
11
+ name: string;
12
+ rows: string[][];
13
+ columnCount: number;
14
+ truncated: boolean;
15
+ };
16
+
17
+ type SpreadsheetState = {
18
+ contentUrl: string;
19
+ status: "ready" | "error";
20
+ sheets: SpreadsheetSheet[];
21
+ };
22
+
23
+ function spreadsheetColumnLabel(index: number): string {
24
+ let label = "";
25
+ let value = index + 1;
26
+ while (value > 0) {
27
+ value -= 1;
28
+ label = String.fromCharCode(65 + (value % 26)) + label;
29
+ value = Math.floor(value / 26);
30
+ }
31
+ return label;
32
+ }
33
+
34
+ export function WorkspaceSpreadsheetPreview({
35
+ contentUrl,
36
+ }: {
37
+ contentUrl: string;
38
+ }) {
39
+ const fileBuffer = useWorkspaceFileBuffer(contentUrl);
40
+ const [spreadsheetState, setSpreadsheetState] =
41
+ useState<SpreadsheetState | null>(null);
42
+ const [selectedSheetName, setSelectedSheetName] = useState("");
43
+ const currentState =
44
+ spreadsheetState?.contentUrl === contentUrl ? spreadsheetState : null;
45
+ const status =
46
+ fileBuffer.status === "ready"
47
+ ? (currentState?.status ?? "loading")
48
+ : fileBuffer.status;
49
+ const activeSheet =
50
+ currentState?.sheets.find((sheet) => sheet.name === selectedSheetName) ??
51
+ currentState?.sheets[0] ??
52
+ null;
53
+
54
+ useEffect(() => {
55
+ if (!fileBuffer.data) {
56
+ return undefined;
57
+ }
58
+ let disposed = false;
59
+ void import("xlsx")
60
+ .then((xlsx) => {
61
+ const workbook = xlsx.read(fileBuffer.data as ArrayBuffer, {
62
+ dense: true,
63
+ sheetRows: MAX_PREVIEW_ROWS + 1,
64
+ });
65
+ const sheets = workbook.SheetNames.map((name) => {
66
+ const worksheet = workbook.Sheets[name];
67
+ const allRows = worksheet
68
+ ? xlsx.utils.sheet_to_json<unknown[]>(worksheet, {
69
+ header: 1,
70
+ raw: false,
71
+ defval: "",
72
+ blankrows: false,
73
+ })
74
+ : [];
75
+ const rows = allRows
76
+ .slice(0, MAX_PREVIEW_ROWS)
77
+ .map((row) =>
78
+ row
79
+ .slice(0, MAX_PREVIEW_COLUMNS)
80
+ .map((cell) => String(cell ?? "")),
81
+ );
82
+ return {
83
+ name,
84
+ rows,
85
+ columnCount: Math.min(
86
+ MAX_PREVIEW_COLUMNS,
87
+ rows.reduce((maximum, row) => Math.max(maximum, row.length), 0),
88
+ ),
89
+ truncated:
90
+ allRows.length > MAX_PREVIEW_ROWS ||
91
+ allRows.some((row) => row.length > MAX_PREVIEW_COLUMNS),
92
+ };
93
+ });
94
+ if (!disposed) {
95
+ setSpreadsheetState({ contentUrl, status: "ready", sheets });
96
+ }
97
+ })
98
+ .catch((error: unknown) => {
99
+ if (!disposed) {
100
+ console.error("Failed to render spreadsheet preview", error);
101
+ setSpreadsheetState({ contentUrl, status: "error", sheets: [] });
102
+ }
103
+ });
104
+ return () => {
105
+ disposed = true;
106
+ };
107
+ }, [contentUrl, fileBuffer.data]);
108
+
109
+ return (
110
+ <div
111
+ className="relative flex h-full min-h-0 flex-col bg-white"
112
+ data-testid="workspace-content-spreadsheet"
113
+ >
114
+ <WorkspaceDocumentPreviewState status={status} />
115
+ {currentState && currentState.sheets.length > 1 ? (
116
+ <div className="flex shrink-0 gap-1 overflow-x-auto border-b border-border px-2 py-1.5 custom-scrollbar">
117
+ {currentState.sheets.map((sheet) => (
118
+ <button
119
+ key={sheet.name}
120
+ type="button"
121
+ className={cn(
122
+ "h-7 shrink-0 rounded-md px-2.5 text-xs transition-colors",
123
+ sheet.name === activeSheet?.name
124
+ ? "bg-accent font-medium text-accent-foreground"
125
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
126
+ )}
127
+ onClick={() => setSelectedSheetName(sheet.name)}
128
+ >
129
+ {sheet.name}
130
+ </button>
131
+ ))}
132
+ </div>
133
+ ) : null}
134
+ {activeSheet ? (
135
+ <div className="min-h-0 flex-1 overflow-auto custom-scrollbar">
136
+ <table className="min-w-full border-collapse text-xs text-foreground">
137
+ <thead className="sticky top-0 z-[1] bg-muted">
138
+ <tr>
139
+ <th className="sticky left-0 z-[2] h-7 min-w-10 border border-border bg-muted px-2" />
140
+ {Array.from({ length: activeSheet.columnCount }, (_, index) => (
141
+ <th
142
+ key={index}
143
+ className="h-7 min-w-24 border border-border px-2 text-center font-medium text-muted-foreground"
144
+ >
145
+ {spreadsheetColumnLabel(index)}
146
+ </th>
147
+ ))}
148
+ </tr>
149
+ </thead>
150
+ <tbody>
151
+ {activeSheet.rows.map((row, rowIndex) => (
152
+ <tr key={rowIndex}>
153
+ <th className="sticky left-0 min-w-10 border border-border bg-muted px-2 text-right font-normal text-muted-foreground">
154
+ {rowIndex + 1}
155
+ </th>
156
+ {Array.from(
157
+ { length: activeSheet.columnCount },
158
+ (_, columnIndex) => (
159
+ <td
160
+ key={columnIndex}
161
+ className="max-w-80 whitespace-pre-wrap border border-border px-2 py-1.5 align-top"
162
+ >
163
+ {row[columnIndex] ?? ""}
164
+ </td>
165
+ ),
166
+ )}
167
+ </tr>
168
+ ))}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+ ) : null}
173
+ {activeSheet?.truncated ? (
174
+ <div className="shrink-0 border-t border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
175
+ {t("chatWorkspaceSpreadsheetPreviewTruncated")}
176
+ </div>
177
+ ) : null}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,191 @@
1
+ import { WorkspaceDocxPreview } from "./file-content-preview/workspace-docx-preview";
2
+ import { WorkspacePresentationPreview } from "./file-content-preview/workspace-presentation-preview";
3
+ import { WorkspaceSpreadsheetPreview } from "./file-content-preview/workspace-spreadsheet-preview";
4
+ import { t } from "@/shared/lib/i18n";
5
+
6
+ export type WorkspaceFileContentKind =
7
+ | "image"
8
+ | "audio"
9
+ | "video"
10
+ | "pdf"
11
+ | "html"
12
+ | "docx"
13
+ | "spreadsheet"
14
+ | "presentation"
15
+ | "other";
16
+
17
+ export function resolveWorkspaceFileContentKind(params: {
18
+ path: string;
19
+ mimeType?: string | null;
20
+ }): WorkspaceFileContentKind {
21
+ const mime = params.mimeType?.trim().toLowerCase() ?? "";
22
+ const path = params.path.trim().toLowerCase();
23
+ if (
24
+ mime.startsWith("image/") ||
25
+ /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(path)
26
+ ) {
27
+ return "image";
28
+ }
29
+ if (
30
+ mime.startsWith("audio/") ||
31
+ /\.(aac|flac|m4a|mp3|ogg|opus|wav|weba)$/i.test(path)
32
+ ) {
33
+ return "audio";
34
+ }
35
+ if (
36
+ mime.startsWith("video/") ||
37
+ /\.(avi|m4v|mkv|mov|mp4|webm|wmv)$/i.test(path)
38
+ ) {
39
+ return "video";
40
+ }
41
+ if (mime.includes("pdf") || path.endsWith(".pdf")) {
42
+ return "pdf";
43
+ }
44
+ if (
45
+ mime ===
46
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
47
+ path.endsWith(".docx")
48
+ ) {
49
+ return "docx";
50
+ }
51
+ if (
52
+ mime.includes("spreadsheet") ||
53
+ mime.includes("excel") ||
54
+ /\.(ods|xls|xlsb|xlsm|xlsx)$/i.test(path)
55
+ ) {
56
+ return "spreadsheet";
57
+ }
58
+ if (
59
+ mime ===
60
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
61
+ path.endsWith(".pptx")
62
+ ) {
63
+ return "presentation";
64
+ }
65
+ if (mime.includes("html") || /\.html?$/i.test(path)) {
66
+ return "html";
67
+ }
68
+ return "other";
69
+ }
70
+
71
+ function WorkspaceUnsupportedContent({
72
+ contentUrl,
73
+ label,
74
+ }: {
75
+ contentUrl: string;
76
+ label: string;
77
+ }) {
78
+ return (
79
+ <div
80
+ className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center"
81
+ data-testid="workspace-content-unsupported"
82
+ >
83
+ <div className="max-w-sm space-y-1.5">
84
+ <p
85
+ className="truncate text-sm font-medium text-foreground"
86
+ title={label}
87
+ >
88
+ {label}
89
+ </p>
90
+ <p className="text-sm text-muted-foreground">
91
+ {t("chatWorkspacePreviewUnsupported")}
92
+ </p>
93
+ <p className="text-xs leading-5 text-muted-foreground/80">
94
+ {t("chatWorkspacePreviewUnsupportedHint")}
95
+ </p>
96
+ </div>
97
+ <div className="flex flex-wrap items-center justify-center gap-2">
98
+ <a
99
+ href={contentUrl}
100
+ download={label}
101
+ className="inline-flex h-8 items-center rounded-lg border border-border bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted"
102
+ >
103
+ {t("chatWorkspacePreviewDownload")}
104
+ </a>
105
+ <a
106
+ href={contentUrl}
107
+ target="_blank"
108
+ rel="noreferrer"
109
+ className="inline-flex h-8 items-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
110
+ >
111
+ {t("chatWorkspacePreviewOpenExternally")}
112
+ </a>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export function WorkspaceFileContentPreview({
119
+ contentUrl,
120
+ kind,
121
+ label,
122
+ }: {
123
+ contentUrl: string;
124
+ kind: WorkspaceFileContentKind;
125
+ label: string;
126
+ }) {
127
+ if (kind === "image") {
128
+ return (
129
+ <div className="flex h-full items-center justify-center overflow-auto bg-white p-4 custom-scrollbar">
130
+ <img
131
+ src={contentUrl}
132
+ alt={label}
133
+ className="max-h-full max-w-full object-contain"
134
+ data-testid="workspace-content-image"
135
+ />
136
+ </div>
137
+ );
138
+ }
139
+ if (kind === "audio") {
140
+ return (
141
+ <div className="flex h-full items-center justify-center bg-white px-6">
142
+ <audio
143
+ controls
144
+ preload="metadata"
145
+ aria-label={label}
146
+ className="w-full max-w-xl"
147
+ data-testid="workspace-content-audio"
148
+ src={contentUrl}
149
+ />
150
+ </div>
151
+ );
152
+ }
153
+ if (kind === "video") {
154
+ return (
155
+ <div className="flex h-full items-center justify-center bg-black">
156
+ <video
157
+ controls
158
+ playsInline
159
+ preload="metadata"
160
+ aria-label={label}
161
+ className="max-h-full max-w-full"
162
+ data-testid="workspace-content-video"
163
+ src={contentUrl}
164
+ />
165
+ </div>
166
+ );
167
+ }
168
+ if (kind === "pdf" || kind === "html") {
169
+ return (
170
+ <iframe
171
+ allowFullScreen
172
+ className="h-full w-full border-0 bg-white"
173
+ data-testid={
174
+ kind === "pdf" ? "workspace-content-pdf" : "workspace-html-preview"
175
+ }
176
+ src={contentUrl}
177
+ title={label}
178
+ />
179
+ );
180
+ }
181
+ if (kind === "docx") {
182
+ return <WorkspaceDocxPreview contentUrl={contentUrl} />;
183
+ }
184
+ if (kind === "spreadsheet") {
185
+ return <WorkspaceSpreadsheetPreview contentUrl={contentUrl} />;
186
+ }
187
+ if (kind === "presentation") {
188
+ return <WorkspacePresentationPreview contentUrl={contentUrl} />;
189
+ }
190
+ return <WorkspaceUnsupportedContent contentUrl={contentUrl} label={label} />;
191
+ }
@@ -64,9 +64,6 @@ export function useChatConversationWorkspaceState(
64
64
  sessionCronJobs,
65
65
  showWorkspacePanel:
66
66
  snapshot.workspacePanelParentKey === sessionKey &&
67
- (activeSideChatDraft !== null ||
68
- childSessionTabs.length > 0 ||
69
- workspaceFileTabs.length > 0 ||
70
- sessionCronJobs.length > 0),
67
+ snapshot.activeWorkspacePanelKind != null,
71
68
  };
72
69
  }
@@ -4,6 +4,7 @@ import {
4
4
  buildWorkspaceTabsViewModel,
5
5
  resolveWorkspaceSelection,
6
6
  } from "@/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils";
7
+ import { t } from "@/shared/lib/i18n";
7
8
 
8
9
  function createChildTab(
9
10
  overrides: Partial<ResolvedChildSessionTab> = {},
@@ -26,6 +27,19 @@ function createChildTab(
26
27
  }
27
28
 
28
29
  describe("resolveWorkspaceSelection", () => {
30
+ it("keeps the explicit workspace overview even when no resources exist", () => {
31
+ expect(
32
+ resolveWorkspaceSelection({
33
+ activePanelKind: "overview",
34
+ activeChildSessionKey: null,
35
+ activeSideChatDraft: null,
36
+ activeWorkspaceFileKey: null,
37
+ childSessionTabs: [],
38
+ workspaceFileTabs: [],
39
+ }),
40
+ ).toEqual({ kind: "overview" });
41
+ });
42
+
29
43
  it("honors the explicit active panel kind before fallback order", () => {
30
44
  const childTab = createChildTab();
31
45
  const fileTab = {
@@ -43,7 +57,6 @@ describe("resolveWorkspaceSelection", () => {
43
57
  activeWorkspaceFileKey: fileTab.key,
44
58
  childSessionTabs: [childTab],
45
59
  workspaceFileTabs: [fileTab],
46
- sessionCronJobCount: 1,
47
60
  }),
48
61
  ).toMatchObject({
49
62
  kind: "file",
@@ -62,7 +75,6 @@ describe("resolveWorkspaceSelection", () => {
62
75
  activeWorkspaceFileKey: null,
63
76
  childSessionTabs: [childTab],
64
77
  workspaceFileTabs: [],
65
- sessionCronJobCount: 1,
66
78
  }),
67
79
  ).toMatchObject({
68
80
  kind: "child-session",
@@ -84,7 +96,6 @@ describe("resolveWorkspaceSelection", () => {
84
96
  activeWorkspaceFileKey: null,
85
97
  childSessionTabs: [createChildTab()],
86
98
  workspaceFileTabs: [],
87
- sessionCronJobCount: 0,
88
99
  }),
89
100
  ).toMatchObject({
90
101
  kind: "side-chat-draft",
@@ -105,16 +116,19 @@ describe("buildWorkspaceTabsViewModel", () => {
105
116
  resolvedChildTabs: [childTab],
106
117
  activeSideChatDraft: null,
107
118
  workspaceFileTabs: [],
108
- sessionCronJobCount: 0,
109
119
  activeSelection: null,
110
120
  optimisticReadAtBySessionKey: {},
111
121
  onSelectSession,
112
122
  onSelectFile: vi.fn(),
123
+ onOpenFileViewer: vi.fn(),
113
124
  onCloseFile: vi.fn(),
125
+ onSelectOverview: vi.fn(),
126
+ onSelectChildSessions: vi.fn(),
127
+ onSelectProjectFiles: vi.fn(),
114
128
  onSelectCronJobs: vi.fn(),
115
129
  });
116
130
 
117
- expect(tabs[0]).toMatchObject({
131
+ expect(tabs[4]).toMatchObject({
118
132
  key: "child:child-1",
119
133
  kind: "child-session",
120
134
  title: "Child session",
@@ -122,7 +136,7 @@ describe("buildWorkspaceTabsViewModel", () => {
122
136
  showUnreadDot: true,
123
137
  });
124
138
 
125
- tabs[0]?.onSelect();
139
+ tabs[4]?.onSelect();
126
140
  expect(onSelectSession).toHaveBeenCalledWith("child-1");
127
141
  });
128
142
 
@@ -137,7 +151,6 @@ describe("buildWorkspaceTabsViewModel", () => {
137
151
  resolvedChildTabs: [childTab],
138
152
  activeSideChatDraft: draft,
139
153
  workspaceFileTabs: [],
140
- sessionCronJobCount: 0,
141
154
  activeSelection: {
142
155
  kind: "side-chat-draft",
143
156
  draft,
@@ -145,15 +158,23 @@ describe("buildWorkspaceTabsViewModel", () => {
145
158
  optimisticReadAtBySessionKey: {},
146
159
  onSelectSession: vi.fn(),
147
160
  onSelectFile: vi.fn(),
161
+ onOpenFileViewer: vi.fn(),
148
162
  onCloseFile: vi.fn(),
163
+ onSelectOverview: vi.fn(),
164
+ onSelectChildSessions: vi.fn(),
165
+ onSelectProjectFiles: vi.fn(),
149
166
  onSelectCronJobs: vi.fn(),
150
167
  });
151
168
 
152
169
  expect(tabs.map((tab) => tab.key)).toEqual([
170
+ "overview",
171
+ "child-sessions",
172
+ "cron:session",
173
+ "project-files",
153
174
  "side-chat-draft:draft-1",
154
175
  "child:child-1",
155
176
  ]);
156
- expect(tabs[0]).toMatchObject({
177
+ expect(tabs[4]).toMatchObject({
157
178
  kind: "side-chat-draft",
158
179
  active: true,
159
180
  });
@@ -177,11 +198,11 @@ describe("buildWorkspaceTabsViewModel", () => {
177
198
  previewViewer: "source" as const,
178
199
  };
179
200
 
201
+ const onOpenFileViewer = vi.fn();
180
202
  const tabs = buildWorkspaceTabsViewModel({
181
203
  resolvedChildTabs: [],
182
204
  activeSideChatDraft: null,
183
205
  workspaceFileTabs: [sourceTab, renderedTab],
184
- sessionCronJobCount: 0,
185
206
  activeSelection: {
186
207
  kind: "file",
187
208
  file: sourceTab,
@@ -189,19 +210,41 @@ describe("buildWorkspaceTabsViewModel", () => {
189
210
  optimisticReadAtBySessionKey: {},
190
211
  onSelectSession: vi.fn(),
191
212
  onSelectFile: vi.fn(),
213
+ onOpenFileViewer,
192
214
  onCloseFile: vi.fn(),
215
+ onSelectOverview: vi.fn(),
216
+ onSelectChildSessions: vi.fn(),
217
+ onSelectProjectFiles: vi.fn(),
193
218
  onSelectCronJobs: vi.fn(),
194
219
  });
195
220
 
196
- expect(tabs).toEqual([
221
+ expect(tabs.slice(4)).toEqual([
197
222
  expect.objectContaining({
198
223
  key: "file:parent::preview::demo.html",
199
224
  active: true,
225
+ fileName: "demo.html",
226
+ title: "demo.html",
227
+ isRenderedPreview: false,
228
+ alternateViewerAction: expect.objectContaining({
229
+ label: t("chatWorkspaceOpenPreview"),
230
+ viewer: "rendered",
231
+ }),
200
232
  }),
201
233
  expect.objectContaining({
202
234
  key: "file:parent::preview:rendered::demo.html",
203
235
  active: false,
236
+ title: `${t("chatWorkspacePreview")}: demo.html`,
237
+ isRenderedPreview: true,
238
+ alternateViewerAction: expect.objectContaining({
239
+ label: t("chatWorkspaceOpenSource"),
240
+ viewer: "source",
241
+ }),
204
242
  }),
205
243
  ]);
244
+ tabs[4]?.alternateViewerAction?.onSelect();
245
+ expect(onOpenFileViewer).toHaveBeenCalledWith(
246
+ "parent::preview::demo.html",
247
+ "rendered",
248
+ );
206
249
  });
207
250
  });
@@ -16,10 +16,76 @@ export function areWorkspaceNavigationEntriesEqual(
16
16
  if (current.kind !== next.kind) {
17
17
  return false;
18
18
  }
19
- if (current.kind === 'cron') {
19
+ if (
20
+ current.kind === 'overview' ||
21
+ current.kind === 'child-sessions' ||
22
+ current.kind === 'project-files' ||
23
+ current.kind === 'cron'
24
+ ) {
20
25
  return true;
21
26
  }
22
- return next.kind !== 'cron' && current.key === next.key;
27
+ return (
28
+ next.kind !== 'overview' &&
29
+ next.kind !== 'child-sessions' &&
30
+ next.kind !== 'project-files' &&
31
+ next.kind !== 'cron' &&
32
+ current.key === next.key
33
+ );
34
+ }
35
+
36
+ export function createWorkspaceSelectionPatch(
37
+ entry: ChatWorkspaceNavigationEntry,
38
+ snapshot: ChatThreadSnapshot,
39
+ ): Partial<ChatThreadSnapshot> | null {
40
+ if (
41
+ entry.kind === 'overview' ||
42
+ entry.kind === 'child-sessions' ||
43
+ entry.kind === 'project-files'
44
+ ) {
45
+ return {
46
+ activeWorkspacePanelKind: entry.kind,
47
+ activeChildSessionKey: null,
48
+ activeSideChatDraft: null,
49
+ activeWorkspaceFileKey: null,
50
+ };
51
+ }
52
+ if (entry.kind === 'cron') {
53
+ return {
54
+ activeWorkspacePanelKind: 'cron',
55
+ activeChildSessionKey: null,
56
+ activeSideChatDraft: null,
57
+ activeWorkspaceFileKey: null,
58
+ };
59
+ }
60
+ if (entry.kind === 'file') {
61
+ if (!snapshot.workspaceFileTabs.some((tab) => tab.key === entry.key)) {
62
+ return null;
63
+ }
64
+ return {
65
+ activeWorkspacePanelKind: 'file',
66
+ activeChildSessionKey: null,
67
+ activeSideChatDraft: null,
68
+ activeWorkspaceFileKey: entry.key,
69
+ };
70
+ }
71
+ if (entry.kind === 'side-chat-draft') {
72
+ const { activeSideChatDraft } = snapshot;
73
+ if (activeSideChatDraft?.draftKey !== entry.key) {
74
+ return null;
75
+ }
76
+ return {
77
+ activeWorkspacePanelKind: 'side-chat-draft',
78
+ activeChildSessionKey: null,
79
+ activeSideChatDraft,
80
+ activeWorkspaceFileKey: null,
81
+ };
82
+ }
83
+ return {
84
+ activeWorkspacePanelKind: 'child-session',
85
+ activeChildSessionKey: entry.key,
86
+ activeSideChatDraft: null,
87
+ activeWorkspaceFileKey: null,
88
+ };
23
89
  }
24
90
 
25
91
  export function createSideChatDraft(parentSessionKey: string): ChatWorkspaceSideChatDraft {
@@ -85,3 +85,20 @@ export function toPersistedWorkspaceFileTab(
85
85
  newStartLine: tab.newStartLine,
86
86
  };
87
87
  }
88
+
89
+ export function retainWorkspaceFileTabs(
90
+ tabs: readonly ChatWorkspaceFileTab[],
91
+ activeWorkspaceFileKey: string | null | undefined,
92
+ maxTabs: number,
93
+ ): ChatWorkspaceFileTab[] {
94
+ const retained = tabs.slice(0, maxTabs);
95
+ const activeTab = activeWorkspaceFileKey
96
+ ? tabs.find((tab) => tab.key === activeWorkspaceFileKey)
97
+ : null;
98
+ if (!activeTab || retained.some((tab) => tab.key === activeTab.key)) {
99
+ return retained;
100
+ }
101
+ return retained.length < maxTabs
102
+ ? [...retained, activeTab]
103
+ : [...retained.slice(0, -1), activeTab];
104
+ }