@nextclaw/ui 0.15.23 → 0.15.25

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 (119) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/assets/{api-Glw2OGXK.js → api-FK7tikI1.js} +4 -4
  3. package/dist/assets/appearance-settings-page-BYbvl2wj.js +1 -0
  4. package/dist/assets/{channels-list-page-C7E4Sxwd.js → channels-list-page-CocdcIko.js} +2 -2
  5. package/dist/assets/{chat-page-BT6dNI-4.js → chat-page-C5E-LLVd.js} +1 -1
  6. package/dist/assets/config-split-page-CA_a0AJ5.js +1 -0
  7. package/dist/assets/confirm-dialog-CxZfenjK.js +5 -0
  8. package/dist/assets/desktop-update-config-BEKC8Ly9.js +1 -0
  9. package/dist/assets/{dist-dv_SUR54.js → dist-BH7W5yCi.js} +1 -1
  10. package/dist/assets/{dist-CtLyEJeO.js → dist-CaYdVVeO.js} +1 -1
  11. package/dist/assets/doc-browser-Cl2ldE3f.js +1 -0
  12. package/dist/assets/doc-browser-DXCSd63E.js +1 -0
  13. package/dist/assets/{doc-browser-context-BIBjkwfV.js → doc-browser-context-FDPs3gJf.js} +1 -1
  14. package/dist/assets/form-actions-0c0aw_bl.js +1 -0
  15. package/dist/assets/i18n-provider-DkITJNz8.js +1 -0
  16. package/dist/assets/index-CQ2LcI2p.css +1 -0
  17. package/dist/assets/index-CrdfvBVg.js +107 -0
  18. package/dist/assets/loader-circle-DHtUxCIA.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-Bc_jI5iH.js +9 -0
  20. package/dist/assets/mcp-marketplace-page-CzCieD9r.js +1 -0
  21. package/dist/assets/model-config-page-C4jw-pcj.js +1 -0
  22. package/dist/assets/{navigation-link-CB0OQbnY.js → navigation-link-CVy0_kVl.js} +2 -2
  23. package/dist/assets/plus-CG1RwZQq.js +1 -0
  24. package/dist/assets/provider-scoped-model-input-CmH4BFvc.js +1 -0
  25. package/dist/assets/providers-config-page-CJQjwzaw.js +1 -0
  26. package/dist/assets/react-DjFBVA0n.js +8 -0
  27. package/dist/assets/{refresh-cw-Ba0LZou4.js → refresh-cw-j2bl79Ea.js} +1 -1
  28. package/dist/assets/remote-BIlpD3yL.js +1 -0
  29. package/dist/assets/rotate-cw-bKG8zH8M.js +1 -0
  30. package/dist/assets/runtime-config-page-B_x2Glz5.js +1 -0
  31. package/dist/assets/{save-DTOAvqzt.js → save-BKNrtK81.js} +1 -1
  32. package/dist/assets/{search-D4X5DNMe.js → search-CkOm65W_.js} +1 -1
  33. package/dist/assets/{search-config-page-Cr6254K5.js → search-config-page-BaIijtmO.js} +1 -1
  34. package/dist/assets/{secrets-config-page-W2Hx332d.js → secrets-config-page-CJjtdFAl.js} +2 -2
  35. package/dist/assets/security-config-DB8apj09.js +1 -0
  36. package/dist/assets/{select-CDv0g2Pj.js → select-Ea7ffKCE.js} +2 -2
  37. package/dist/assets/{settings-2-CoxzNABp.js → settings-2-CtyN3m_V.js} +1 -1
  38. package/dist/assets/skeleton-Bt6zxcAB.js +1 -0
  39. package/dist/assets/{tag-chip-CjkZEoS2.js → tag-chip-C9l4xsKp.js} +1 -1
  40. package/dist/assets/x-CowtCu9Z.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +8 -8
  43. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +30 -1
  44. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -1
  45. package/src/features/chat/features/conversation/hooks/__tests__/use-session-provider-model-catalog.test.tsx +54 -2
  46. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +10 -1
  47. package/src/features/chat/features/conversation/hooks/use-session-provider-model-catalog.ts +76 -1
  48. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +6 -1
  49. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
  50. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +12 -3
  51. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  52. package/src/features/chat/features/input/utils/chat-conversation-excerpt-token.utils.ts +59 -0
  53. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +42 -16
  54. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +50 -14
  55. package/src/features/chat/features/message/components/chat-message-list.container.tsx +40 -0
  56. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +5 -0
  57. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +40 -3
  58. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.selection.test.tsx +66 -0
  59. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +13 -1
  60. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +573 -90
  61. package/src/features/chat/features/workspace/components/__tests__/workspace-text-selection-menu.test.tsx +11 -4
  62. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +389 -288
  63. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +54 -35
  64. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +79 -148
  65. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +149 -94
  66. package/src/features/chat/features/workspace/components/project-files/__tests__/workspace-directory-entry-menu.test.tsx +50 -0
  67. package/src/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu.tsx +209 -0
  68. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +411 -0
  69. package/src/features/chat/features/workspace/components/project-files/workspace-new-folder-tree-item.tsx +112 -0
  70. package/src/features/chat/features/workspace/components/project-files/workspace-project-files-toolbar.tsx +64 -0
  71. package/src/features/chat/features/workspace/components/workspace-text-selection-menu.tsx +17 -195
  72. package/src/features/chat/features/workspace/hooks/use-workspace-explorer-layout.ts +88 -0
  73. package/src/features/chat/features/workspace/hooks/use-workspace-file-actions.ts +335 -0
  74. package/src/features/chat/features/workspace/utils/chat-workspace-file-path-mutation.utils.ts +58 -0
  75. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +23 -22
  76. package/src/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils.ts +13 -4
  77. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +94 -45
  78. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +103 -99
  79. package/src/features/chat/managers/chat-composer-intent.manager.ts +83 -34
  80. package/src/features/chat/managers/chat-thread.manager.ts +132 -128
  81. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +20 -40
  82. package/src/features/chat/stores/chat-thread.store.ts +47 -74
  83. package/src/features/chat/types/chat-input-bar.types.ts +3 -0
  84. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +1 -0
  85. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +0 -9
  86. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +35 -38
  87. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +25 -19
  88. package/src/shared/components/ui/context-menu/__tests__/context-menu.test.tsx +60 -34
  89. package/src/shared/components/ui/context-menu/context-menu.tsx +95 -68
  90. package/src/shared/lib/api/managers/client.manager.ts +31 -17
  91. package/src/shared/lib/api/server-path/server-path.types.ts +66 -0
  92. package/src/shared/lib/api/types.ts +145 -116
  93. package/src/shared/lib/api/utils/server-path.utils.ts +35 -11
  94. package/src/shared/lib/i18n/locales/en-US/chat.json +49 -1
  95. package/src/shared/lib/i18n/locales/zh-CN/chat.json +49 -1
  96. package/dist/assets/appearance-settings-page-Bl_W_9m7.js +0 -1
  97. package/dist/assets/config-split-page-D5BgtKrm.js +0 -1
  98. package/dist/assets/confirm-dialog-Bf907Azu.js +0 -5
  99. package/dist/assets/desktop-update-config-BwuyBPJb.js +0 -1
  100. package/dist/assets/doc-browser-C23NDGh5.js +0 -1
  101. package/dist/assets/doc-browser-DlOOhBxI.js +0 -1
  102. package/dist/assets/form-actions-DAzi68Cn.js +0 -1
  103. package/dist/assets/index-BLNg2yg0.css +0 -1
  104. package/dist/assets/index-D3EKjEyK.js +0 -107
  105. package/dist/assets/loader-circle-BSg5u0r4.js +0 -1
  106. package/dist/assets/mcp-marketplace-page-ByhvQQj5.js +0 -9
  107. package/dist/assets/mcp-marketplace-page-CytXwnUs.js +0 -1
  108. package/dist/assets/model-config-page-bgEx3MjQ.js +0 -1
  109. package/dist/assets/plus-CNjWdLhZ.js +0 -1
  110. package/dist/assets/provider-scoped-model-input-8F6JMd2n.js +0 -1
  111. package/dist/assets/providers-config-page-CxpTjR81.js +0 -1
  112. package/dist/assets/react-Bk2ydNTe.js +0 -8
  113. package/dist/assets/remote-Dv38PuMV.js +0 -1
  114. package/dist/assets/rotate-cw-IkYVk9JX.js +0 -1
  115. package/dist/assets/runtime-config-page-C0ByyUVr.js +0 -1
  116. package/dist/assets/security-config-1E8OF4kR.js +0 -1
  117. package/dist/assets/skeleton-DW018fJv.js +0 -1
  118. package/dist/assets/use-confirm-dialog-CavtY1vJ.js +0 -1
  119. package/dist/assets/x-PTXYTEwz.js +0 -1
@@ -0,0 +1,112 @@
1
+ import { useLayoutEffect, useRef, type FormEvent } from 'react';
2
+ import { Check, File, Folder, X } from 'lucide-react';
3
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
4
+ import { Input } from '@/shared/components/ui/input';
5
+ import { t } from '@/shared/lib/i18n';
6
+
7
+ function isValidFolderName(value: string): boolean {
8
+ const name = value.trim();
9
+ return Boolean(name && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'));
10
+ }
11
+
12
+ export function WorkspaceNewFolderTreeItem({
13
+ error,
14
+ kind,
15
+ isCreating,
16
+ level,
17
+ name,
18
+ onCancel,
19
+ onNameChange,
20
+ onSubmit,
21
+ }: {
22
+ error: string | null;
23
+ kind: 'directory' | 'file';
24
+ isCreating: boolean;
25
+ level: number;
26
+ name: string;
27
+ onCancel: () => void;
28
+ onNameChange: (name: string) => void;
29
+ onSubmit: () => void;
30
+ }) {
31
+ const rowRef = useRef<HTMLFormElement | null>(null);
32
+ const inputRef = useRef<HTMLInputElement | null>(null);
33
+ const valid = isValidFolderName(name);
34
+
35
+ useLayoutEffect(() => {
36
+ const row = rowRef.current;
37
+ if (typeof row?.scrollIntoView === 'function') {
38
+ row.scrollIntoView({ block: 'nearest' });
39
+ }
40
+ inputRef.current?.focus();
41
+ inputRef.current?.select();
42
+ }, []);
43
+
44
+ const submit = (event: FormEvent) => {
45
+ event.preventDefault();
46
+ if (valid && !isCreating) {
47
+ onSubmit();
48
+ }
49
+ };
50
+
51
+ return (
52
+ <form
53
+ ref={rowRef}
54
+ role="treeitem"
55
+ aria-label={t(kind === 'file' ? 'chatWorkspaceNewFile' : 'chatWorkspaceNewFolder')}
56
+ className="bg-primary/5"
57
+ onSubmit={submit}
58
+ >
59
+ <div
60
+ className="flex min-h-8 min-w-0 items-center gap-1 py-0.5 pr-1"
61
+ style={{ paddingLeft: `${8 + level * 14}px` }}
62
+ >
63
+ <span className="inline-flex h-4 w-4 shrink-0" />
64
+ {kind === 'file' ? (
65
+ <File className="h-4 w-4 shrink-0 text-gray-500" />
66
+ ) : (
67
+ <Folder className="h-4 w-4 shrink-0 text-amber-500" />
68
+ )}
69
+ <Input
70
+ ref={inputRef}
71
+ aria-label={t(kind === 'file' ? 'chatWorkspaceFileName' : 'chatWorkspaceFolderName')}
72
+ className="h-7 min-w-0 flex-1 rounded-md px-2 text-xs"
73
+ disabled={isCreating}
74
+ placeholder={t(kind === 'file' ? 'chatWorkspaceFileNamePlaceholder' : 'chatWorkspaceFolderNamePlaceholder')}
75
+ value={name}
76
+ onChange={(event) => onNameChange(event.target.value)}
77
+ onKeyDown={(event) => {
78
+ if (event.key === 'Escape') {
79
+ event.preventDefault();
80
+ onCancel();
81
+ }
82
+ }}
83
+ />
84
+ <IconActionButton
85
+ disabled={!valid || isCreating}
86
+ icon={<Check className="h-3.5 w-3.5" />}
87
+ label={t('chatWorkspaceCreateFolder')}
88
+ size="sm"
89
+ tooltipSide="top"
90
+ onClick={onSubmit}
91
+ />
92
+ <IconActionButton
93
+ disabled={isCreating}
94
+ icon={<X className="h-3.5 w-3.5" />}
95
+ label={t('cancel')}
96
+ size="sm"
97
+ tooltipSide="top"
98
+ onClick={onCancel}
99
+ />
100
+ </div>
101
+ {error ? (
102
+ <p
103
+ role="alert"
104
+ className="pb-1 pr-2 text-[11px] text-destructive"
105
+ style={{ paddingLeft: `${42 + level * 14}px` }}
106
+ >
107
+ {error}
108
+ </p>
109
+ ) : null}
110
+ </form>
111
+ );
112
+ }
@@ -0,0 +1,64 @@
1
+ import { ChevronsUp, FilePlus2, FolderPlus, MoreHorizontal, RefreshCw } from 'lucide-react';
2
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
3
+ import { ContextMenuTrigger } from '@/shared/components/ui/context-menu/context-menu';
4
+ import { t } from '@/shared/lib/i18n';
5
+
6
+ export function WorkspaceProjectFilesToolbar({
7
+ disabled,
8
+ rootLabel,
9
+ onCollapseAll,
10
+ onNewFile,
11
+ onNewFolder,
12
+ onRefresh,
13
+ }: {
14
+ disabled: boolean;
15
+ rootLabel: string;
16
+ onCollapseAll: () => void;
17
+ onNewFile: () => void;
18
+ onNewFolder: () => void;
19
+ onRefresh: () => void;
20
+ }) {
21
+ return (
22
+ <div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-gray-200/80 bg-gray-50/55 px-1.5">
23
+ <span
24
+ className="min-w-0 flex-1 truncate px-0.5 text-[11px] font-semibold uppercase tracking-[0.03em] text-gray-800"
25
+ title={rootLabel}
26
+ >
27
+ {rootLabel}
28
+ </span>
29
+ <IconActionButton
30
+ icon={<FilePlus2 className="h-4 w-4" />}
31
+ label={t('chatWorkspaceNewFile')}
32
+ size="sm"
33
+ disabled={disabled}
34
+ onClick={onNewFile}
35
+ />
36
+ <IconActionButton
37
+ icon={<FolderPlus className="h-4 w-4" />}
38
+ label={t('chatWorkspaceNewFolder')}
39
+ size="sm"
40
+ disabled={disabled}
41
+ onClick={onNewFolder}
42
+ />
43
+ <IconActionButton
44
+ icon={<RefreshCw className="h-4 w-4" />}
45
+ label={t('chatWorkspaceRefreshExplorer')}
46
+ size="sm"
47
+ onClick={onRefresh}
48
+ />
49
+ <IconActionButton
50
+ icon={<ChevronsUp className="h-4 w-4" />}
51
+ label={t('chatWorkspaceCollapseAll')}
52
+ size="sm"
53
+ onClick={onCollapseAll}
54
+ />
55
+ <ContextMenuTrigger>
56
+ <IconActionButton
57
+ icon={<MoreHorizontal className="h-4 w-4" />}
58
+ label={t('chatWorkspaceRootMoreActions')}
59
+ size="sm"
60
+ />
61
+ </ContextMenuTrigger>
62
+ </div>
63
+ );
64
+ }
@@ -1,12 +1,5 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useRef,
5
- useState,
6
- type ReactNode,
7
- } from "react";
8
- import { createPortal } from "react-dom";
9
- import { MessageSquarePlus } from "lucide-react";
1
+ import type { ReactNode } from "react";
2
+ import { ChatTextSelectionAction } from "@nextclaw/agent-chat-ui";
10
3
  import {
11
4
  buildWorkspaceTextExcerpt,
12
5
  WORKSPACE_TEXT_EXCERPT_MAX_CHARACTERS,
@@ -14,44 +7,6 @@ import {
14
7
  } from "@/features/chat/features/workspace/utils/workspace-text-excerpt.utils";
15
8
  import { t } from "@/shared/lib/i18n";
16
9
 
17
- type SelectionMenuState = {
18
- anchorBottom: number;
19
- anchorCenter: number;
20
- anchorTop: number;
21
- excerpt: WorkspaceTextExcerpt;
22
- tooLong: boolean;
23
- };
24
-
25
- type SelectionMenuPosition = {
26
- left: number;
27
- top: number;
28
- };
29
-
30
- const SELECTION_MENU_VIEWPORT_GAP = 12;
31
- const SELECTION_MENU_ANCHOR_GAP = 10;
32
-
33
- function containsSelection(
34
- root: HTMLElement,
35
- selection: Selection,
36
- ): selection is Selection & { rangeCount: number } {
37
- if (selection.rangeCount === 0 || selection.isCollapsed) {
38
- return false;
39
- }
40
- const range = selection.getRangeAt(0);
41
- return root.contains(range.commonAncestorContainer);
42
- }
43
-
44
- function getSelectionAnchorRect(range: Range): DOMRect {
45
- const rects = typeof range.getClientRects === "function"
46
- ? Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0)
47
- : [];
48
- return rects.at(-1) ?? range.getBoundingClientRect();
49
- }
50
-
51
- function clamp(value: number, minimum: number, maximum: number): number {
52
- return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
53
- }
54
-
55
10
  export function WorkspaceTextSelectionMenu({
56
11
  children,
57
12
  fileLabel,
@@ -67,157 +22,24 @@ export function WorkspaceTextSelectionMenu({
67
22
  sourceStartLine?: number | null;
68
23
  sourceText: string;
69
24
  }) {
70
- const rootRef = useRef<HTMLDivElement>(null);
71
- const menuButtonRef = useRef<HTMLButtonElement | null>(null);
72
- const selectionReadFrameRef = useRef<number | null>(null);
73
- const [menu, setMenu] = useState<SelectionMenuState | null>(null);
74
- const [menuPosition, setMenuPosition] = useState<SelectionMenuPosition | null>(null);
75
-
76
- const readSelection = useCallback(() => {
77
- const root = rootRef.current;
78
- const selection = window.getSelection();
79
- if (!root || !selection || !containsSelection(root, selection)) {
80
- setMenu(null);
81
- return;
82
- }
83
- const excerpt = buildWorkspaceTextExcerpt({
84
- path: filePath,
85
- label: fileLabel,
86
- selectedText: selection.toString(),
87
- sourceText,
88
- sourceStartLine,
89
- });
90
- if (!excerpt) {
91
- setMenu(null);
92
- return;
93
- }
94
- const rect = getSelectionAnchorRect(selection.getRangeAt(0));
95
- setMenuPosition(null);
96
- setMenu({
97
- anchorBottom: rect.bottom,
98
- anchorCenter: rect.left + rect.width / 2,
99
- anchorTop: rect.top,
100
- excerpt,
101
- tooLong: excerpt.excerpt.length > WORKSPACE_TEXT_EXCERPT_MAX_CHARACTERS,
102
- });
103
- }, [fileLabel, filePath, sourceStartLine, sourceText]);
104
-
105
- const scheduleSelectionRead = useCallback(() => {
106
- if (selectionReadFrameRef.current !== null) {
107
- window.cancelAnimationFrame(selectionReadFrameRef.current);
108
- }
109
- selectionReadFrameRef.current = window.requestAnimationFrame(() => {
110
- selectionReadFrameRef.current = null;
111
- readSelection();
112
- });
113
- }, [readSelection]);
114
-
115
- const captureMenuButton = useCallback((button: HTMLButtonElement | null) => {
116
- menuButtonRef.current = button;
117
- if (!menu || !button) {
118
- return;
119
- }
120
- const buttonRect = button.getBoundingClientRect();
121
- const left = clamp(
122
- menu.anchorCenter - buttonRect.width / 2,
123
- SELECTION_MENU_VIEWPORT_GAP,
124
- window.innerWidth - SELECTION_MENU_VIEWPORT_GAP - buttonRect.width,
125
- );
126
- const belowTop = menu.anchorBottom + SELECTION_MENU_ANCHOR_GAP;
127
- const aboveTop = menu.anchorTop - SELECTION_MENU_ANCHOR_GAP - buttonRect.height;
128
- const fitsBelow = belowTop + buttonRect.height <= window.innerHeight - SELECTION_MENU_VIEWPORT_GAP;
129
- const preferredTop = fitsBelow ? belowTop : aboveTop;
130
- const top = clamp(
131
- preferredTop,
132
- SELECTION_MENU_VIEWPORT_GAP,
133
- window.innerHeight - SELECTION_MENU_VIEWPORT_GAP - buttonRect.height,
134
- );
135
- setMenuPosition({ left, top });
136
- }, [menu]);
137
-
138
- useEffect(() => {
139
- const handleSelectionChange = () => {
140
- const selection = window.getSelection();
141
- if (!selection || selection.isCollapsed) {
142
- setMenu(null);
143
- return;
144
- }
145
- scheduleSelectionRead();
146
- };
147
- const handlePointerUp = (event: PointerEvent) => {
148
- if (menuButtonRef.current?.contains(event.target as Node)) {
149
- return;
150
- }
151
- scheduleSelectionRead();
152
- };
153
- const handleKeyDown = (event: KeyboardEvent) => {
154
- if (event.key === "Escape") {
155
- setMenu(null);
156
- }
157
- };
158
- const closeMenu = () => setMenu(null);
159
- document.addEventListener("selectionchange", handleSelectionChange);
160
- document.addEventListener("pointerup", handlePointerUp, true);
161
- document.addEventListener("keydown", handleKeyDown);
162
- document.addEventListener("scroll", closeMenu, true);
163
- window.addEventListener("resize", closeMenu);
164
- return () => {
165
- document.removeEventListener("selectionchange", handleSelectionChange);
166
- document.removeEventListener("pointerup", handlePointerUp, true);
167
- document.removeEventListener("keydown", handleKeyDown);
168
- document.removeEventListener("scroll", closeMenu, true);
169
- window.removeEventListener("resize", closeMenu);
170
- if (selectionReadFrameRef.current !== null) {
171
- window.cancelAnimationFrame(selectionReadFrameRef.current);
172
- }
173
- };
174
- }, [scheduleSelectionRead]);
175
-
176
- const addSelectionToChat = () => {
177
- if (!menu || menu.tooLong) {
178
- return;
179
- }
180
- onAddToChat?.(menu.excerpt);
181
- window.getSelection()?.removeAllRanges();
182
- setMenu(null);
183
- };
184
-
185
- const menuNode = menu && onAddToChat
186
- ? createPortal(
187
- <button
188
- ref={captureMenuButton}
189
- type="button"
190
- className="fixed z-[var(--z-popover,10100)] inline-flex max-w-[calc(100vw-24px)] items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-xl border border-border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-lg transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:text-muted-foreground"
191
- style={{
192
- left: menuPosition?.left ?? 0,
193
- top: menuPosition?.top ?? 0,
194
- visibility: menuPosition ? "visible" : "hidden",
195
- }}
196
- disabled={menu.tooLong}
197
- onMouseDown={(event) => event.preventDefault()}
198
- onClick={addSelectionToChat}
199
- >
200
- <MessageSquarePlus aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
201
- <span className="truncate">
202
- {menu.tooLong
203
- ? t("chatWorkspaceExcerptSelectionTooLong")
204
- : t("chatWorkspaceAddToChat")}
205
- </span>
206
- </button>,
207
- document.body,
208
- )
209
- : null;
210
-
211
25
  return (
212
- <div
213
- ref={rootRef}
26
+ <ChatTextSelectionAction
27
+ actionLabel={t("chatWorkspaceAddToChat")}
214
28
  className="h-full min-h-0"
215
- data-workspace-text-selection-root="true"
216
- onMouseUp={scheduleSelectionRead}
217
- onKeyUp={scheduleSelectionRead}
29
+ maxCharacters={WORKSPACE_TEXT_EXCERPT_MAX_CHARACTERS}
30
+ selectionTooLongLabel={t("chatWorkspaceExcerptSelectionTooLong")}
31
+ onAddToChat={({ text }) => {
32
+ const excerpt = buildWorkspaceTextExcerpt({
33
+ path: filePath,
34
+ label: fileLabel,
35
+ selectedText: text,
36
+ sourceText,
37
+ sourceStartLine,
38
+ });
39
+ if (excerpt) onAddToChat?.(excerpt);
40
+ }}
218
41
  >
219
42
  {children}
220
- {menuNode}
221
- </div>
43
+ </ChatTextSelectionAction>
222
44
  );
223
45
  }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
2
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
3
+ import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
4
+ import {
5
+ CHAT_WORKSPACE_EXPLORER_COMPACT_THRESHOLD,
6
+ normalizeChatWorkspaceExplorerWidth,
7
+ } from '@/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils';
8
+
9
+ export function useWorkspaceExplorerLayout({
10
+ fileActive,
11
+ projectFilesActive,
12
+ sessionProjectRoot,
13
+ sessionWorkingDir,
14
+ }: {
15
+ fileActive: boolean;
16
+ projectFilesActive: boolean;
17
+ sessionProjectRoot: string | null;
18
+ sessionWorkingDir: string | null;
19
+ }) {
20
+ const presenter = usePresenter();
21
+ const containerRef = useRef<HTMLDivElement | null>(null);
22
+ const resizeRef = useRef<{ startX: number; startWidth: number; width: number } | null>(null);
23
+ const persistedExplorerWidth = useChatThreadStore((state) => state.snapshot.workspaceExplorerWidth);
24
+ const [compact, setCompact] = useState(false);
25
+ const [explorerDragWidth, setExplorerDragWidth] = useState<number | null>(null);
26
+ const [explorerOpen, setExplorerOpen] = useState(true);
27
+ const explorerWidth = explorerDragWidth ?? persistedExplorerWidth;
28
+
29
+ useEffect(() => {
30
+ const node = containerRef.current;
31
+ if (!node || typeof ResizeObserver === 'undefined') return;
32
+ const update = () => setCompact(node.getBoundingClientRect().width < CHAT_WORKSPACE_EXPLORER_COMPACT_THRESHOLD);
33
+ update();
34
+ const observer = new ResizeObserver(update);
35
+ observer.observe(node);
36
+ return () => observer.disconnect();
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ setExplorerOpen(true);
41
+ }, [sessionProjectRoot, sessionWorkingDir]);
42
+
43
+ useEffect(() => {
44
+ if (!fileActive || !compact || !explorerOpen) return;
45
+ const closeOnEscape = (event: KeyboardEvent) => {
46
+ if (event.key === 'Escape') setExplorerOpen(false);
47
+ };
48
+ window.addEventListener('keydown', closeOnEscape);
49
+ return () => window.removeEventListener('keydown', closeOnEscape);
50
+ }, [compact, explorerOpen, fileActive]);
51
+
52
+ const onResizeStart = (event: ReactPointerEvent<HTMLDivElement>) => {
53
+ if (compact || !fileActive) return;
54
+ event.preventDefault();
55
+ event.currentTarget.setPointerCapture?.(event.pointerId);
56
+ resizeRef.current = { startX: event.clientX, startWidth: explorerWidth, width: explorerWidth };
57
+ const onMove = (moveEvent: PointerEvent) => {
58
+ const resizing = resizeRef.current;
59
+ if (!resizing) return;
60
+ const width = normalizeChatWorkspaceExplorerWidth(resizing.startWidth + moveEvent.clientX - resizing.startX);
61
+ resizing.width = width;
62
+ setExplorerDragWidth(width);
63
+ };
64
+ const onUp = () => {
65
+ const width = resizeRef.current?.width;
66
+ if (typeof width === 'number') presenter.chatThreadManager.setWorkspaceExplorerWidth(width);
67
+ setExplorerDragWidth(null);
68
+ resizeRef.current = null;
69
+ window.removeEventListener('pointermove', onMove);
70
+ window.removeEventListener('pointerup', onUp);
71
+ window.removeEventListener('pointercancel', onUp);
72
+ };
73
+ window.addEventListener('pointermove', onMove);
74
+ window.addEventListener('pointerup', onUp);
75
+ window.addEventListener('pointercancel', onUp);
76
+ };
77
+
78
+ return {
79
+ compact,
80
+ containerRef,
81
+ explorerOpen,
82
+ explorerOverlay: fileActive && compact && explorerOpen,
83
+ explorerWidth,
84
+ onResizeStart,
85
+ setExplorerOpen,
86
+ showExplorer: projectFilesActive || (fileActive && explorerOpen),
87
+ };
88
+ }