Leon4gr45 commited on
Commit
24b2921
·
verified ·
1 Parent(s): dcd1ab7

Upload folder using huggingface_hub (part 10)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. tools/unknown.py.dox.md +48 -3
  2. tools/vision_load.py +233 -3
  3. tools/vision_load.py.dox.md +62 -3
  4. tools/wait.py +89 -3
  5. tools/wait.py.dox.md +54 -3
  6. update_reqs.py +38 -3
  7. usr/skills/.gitkeep +1 -3
  8. webui/AGENTS.md +60 -3
  9. webui/components/AGENTS.md +77 -3
  10. webui/components/_examples/AGENTS.md +27 -3
  11. webui/components/_examples/_example-component.html +36 -3
  12. webui/components/_examples/_example-store.js +23 -3
  13. webui/components/canvas/AGENTS.md +33 -3
  14. webui/components/canvas/right-canvas-store.js +620 -3
  15. webui/components/canvas/right-canvas.css +384 -3
  16. webui/components/canvas/right-canvas.html +132 -3
  17. webui/components/chat/AGENTS.md +47 -3
  18. webui/components/chat/attachments/attachmentsStore.js +511 -3
  19. webui/components/chat/attachments/dragDropOverlay.html +28 -3
  20. webui/components/chat/attachments/inputPreview.html +30 -3
  21. webui/components/chat/input/bottom-actions-bar.html +98 -3
  22. webui/components/chat/input/bottom-actions.html +161 -3
  23. webui/components/chat/input/chat-bar-input.html +457 -3
  24. webui/components/chat/input/chat-bar.html +52 -3
  25. webui/components/chat/input/composer-banner-store.js +69 -3
  26. webui/components/chat/input/input-store.js +780 -3
  27. webui/components/chat/input/progress.html +57 -3
  28. webui/components/chat/message-queue/message-queue-store.js +245 -3
  29. webui/components/chat/message-queue/message-queue.html +245 -3
  30. webui/components/chat/model-gate-store.js +233 -3
  31. webui/components/chat/model-setup-gate.html +203 -3
  32. webui/components/chat/navigation/chat-navigation-store.js +127 -3
  33. webui/components/chat/top-section/chat-top-store.js +13 -3
  34. webui/components/chat/top-section/chat-top.html +42 -3
  35. webui/components/dropdown/AGENTS.md +26 -3
  36. webui/components/dropdown/dropdown.html +69 -3
  37. webui/components/messages/AGENTS.md +38 -3
  38. webui/components/messages/action-buttons/simple-action-buttons.css +153 -3
  39. webui/components/messages/action-buttons/simple-action-buttons.js +134 -3
  40. webui/components/messages/process-group/process-group-dom.js +76 -3
  41. webui/components/messages/process-group/process-group.css +914 -3
  42. webui/components/messages/resize/message-resize-store.js +137 -3
  43. webui/components/modals/AGENTS.md +34 -3
  44. webui/components/modals/context/context-store.js +132 -3
  45. webui/components/modals/context/context.html +103 -3
  46. webui/components/modals/file-browser/AGENTS.md +42 -3
  47. webui/components/modals/file-browser/file-browser-store.js +1599 -3
  48. webui/components/modals/file-browser/file-browser.html +1154 -3
  49. webui/components/modals/file-browser/rename-modal.html +86 -3
  50. webui/components/modals/file-editor/file-edit-modal.html +162 -3
tools/unknown.py.dox.md CHANGED
@@ -1,3 +1,48 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:33edae4ca6280348d9de290a5f09cf28a9f5cfb2b3b843853693b9dca3404388
3
- size 1887
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # unknown.py DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the `unknown.py` agent tool.
6
+ - This module handles unknown or malformed tool-call requests.
7
+ - Keep this file-level DOX profile synchronized with `unknown.py` because this directory is intentionally flat.
8
+
9
+ ## Ownership
10
+
11
+ - `unknown.py` owns the runtime implementation.
12
+ - `unknown.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
+ - Classes:
14
+ - `Unknown` (`Tool`)
15
+ - `async execute(self, **kwargs)`
16
+
17
+ ## Runtime Contracts
18
+
19
+ - Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
20
+ - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
21
+ - `Unknown` is a `Tool`.
22
+ - `Unknown` defines `execute(...)`.
23
+ - Imported dependency areas include: `extensions.python.system_prompt._11_tools_prompt`, `helpers.tool`.
24
+
25
+ ## Key Concepts
26
+
27
+ - Important called helpers/classes observed in the source: `Response`, `build_tools_prompt`, `self.agent.read_prompt`.
28
+ - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
29
+
30
+ ## Work Guidance
31
+
32
+ - Keep tool output concise, model-readable, and safe for history persistence.
33
+ - Coordinate argument or behavior changes with prompt tool instructions and skill guidance.
34
+ - Respect intervention flow for long-running, external, or user-visible operations.
35
+
36
+ ## Verification
37
+
38
+ - Run targeted tool and prompt-contract tests for changed behavior; smoke-test agent execution when no focused test exists.
39
+ - Related tests observed by source search:
40
+ - `tests/test_file_tree_visualize.py`
41
+ - `tests/test_oauth_providers.py`
42
+ - `tests/test_socketio_unknown_namespace.py`
43
+ - `tests/test_tunnel_remote_link.py`
44
+ - `tests/test_ws_manager.py`
45
+
46
+ ## Child DOX Index
47
+
48
+ No child DOX files.
tools/vision_load.py CHANGED
@@ -1,3 +1,233 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c32e18ed5acc62ee2a24ec59a24c8ad8f55013d4f47b2aaacf2d968e1b835267
3
- size 8861
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mimetypes import guess_type
2
+
3
+ from langchain_core.messages import HumanMessage
4
+
5
+ from helpers import (
6
+ chat_media,
7
+ ephemeral_images,
8
+ files,
9
+ history,
10
+ images,
11
+ parallel_tools,
12
+ runtime,
13
+ )
14
+ from helpers.tool import Response, Tool
15
+ from plugins._model_config.helpers.model_config import (
16
+ build_vision_model,
17
+ get_chat_model_config,
18
+ get_vision_model_config,
19
+ )
20
+
21
+ # image token estimation for context window
22
+ TOKENS_ESTIMATE = 1500
23
+
24
+
25
+ class VisionLoad(Tool):
26
+ async def execute(
27
+ self,
28
+ paths: list[str] | str = [],
29
+ query: str = "",
30
+ **kwargs,
31
+ ) -> Response:
32
+
33
+ self.images_dict = {}
34
+ self.loaded_paths: list[str] = []
35
+ self.skipped_paths: list[str] = []
36
+ self.vision_config = get_vision_model_config(self.agent)
37
+ if isinstance(paths, str):
38
+ paths = [paths]
39
+ if not isinstance(paths, list):
40
+ return Response(
41
+ message="vision_load error: `paths` must be a string or an array.",
42
+ break_loop=False,
43
+ )
44
+
45
+ max_embeds = self._get_max_embeds()
46
+ requested = [
47
+ (str(path or "").strip(), self._display_input_path(str(path or "").strip(), idx + 1))
48
+ for idx, path in enumerate(paths)
49
+ ]
50
+ limited_paths = requested if max_embeds <= 0 else requested[-max_embeds:]
51
+ self.skipped_paths = (
52
+ [display for _, display in requested[:-max_embeds]]
53
+ if max_embeds > 0 and len(requested) > max_embeds
54
+ else []
55
+ )
56
+
57
+ for idx, (path, display_path) in enumerate(limited_paths):
58
+ if not path:
59
+ continue
60
+ if ephemeral_images.is_ref(path):
61
+ image = ephemeral_images.consume_image(
62
+ path,
63
+ context_id=self._context_id(),
64
+ )
65
+ if image is None:
66
+ continue
67
+ display = image.display_name or display_path
68
+ stored_ref = self._store_ephemeral_image(image)
69
+ if stored_ref:
70
+ self.images_dict[display] = stored_ref
71
+ self.loaded_paths.append(display)
72
+ continue
73
+ if self._is_data_image_url(path):
74
+ stored_ref = self._store_data_url(path, preferred_name=f"vision-load-{idx + 1}.png")
75
+ if stored_ref:
76
+ self.images_dict[display_path] = stored_ref
77
+ self.loaded_paths.append(display_path)
78
+ continue
79
+ if not await runtime.call_development_function(files.exists, str(path)):
80
+ continue
81
+
82
+ if path not in self.images_dict:
83
+ mime_type, _ = guess_type(str(path))
84
+ if mime_type and mime_type.startswith("image/"):
85
+ try:
86
+ stored_ref = self._store_local_image(path, preferred_name=files.basename(path))
87
+ self.images_dict[display_path] = stored_ref
88
+ self.loaded_paths.append(display_path)
89
+ except (FileNotFoundError, OSError, ValueError):
90
+ continue
91
+
92
+ message = self._summary() if self.images_dict or self.skipped_paths else "No images processed"
93
+ if self.vision_config and self.images_dict:
94
+ try:
95
+ capsule = await self._call_vision_model(
96
+ list(self.images_dict.values()),
97
+ query,
98
+ )
99
+ message = (
100
+ f"Analyzed {len(self.images_dict)} image(s)"
101
+ f"; {len(self.skipped_paths)} skipped.\n\n{capsule.strip()}"
102
+ )
103
+ except Exception as exc:
104
+ message = f"Image analysis error: {str(exc)[:1000]}"
105
+ return Response(message=message, break_loop=False)
106
+
107
+ def _get_max_embeds(self) -> int:
108
+ cfg = self.vision_config or get_chat_model_config(self.agent)
109
+ return int(cfg.get("max_embeds", 10) or 0)
110
+
111
+ def _context_id(self) -> str:
112
+ context = getattr(self.agent, "context", None)
113
+ get_data = getattr(context, "get_data", None)
114
+ parent_id = (
115
+ get_data(parallel_tools.PARALLEL_WORKER_PARENT_CONTEXT_KEY)
116
+ if get_data
117
+ else ""
118
+ )
119
+ return str(parent_id or getattr(context, "id", "") or "").strip()
120
+
121
+ async def _call_vision_model(self, image_paths: list[str], query: str) -> str:
122
+ user_message = getattr(self.agent, "last_user_message", None)
123
+ output_text = getattr(user_message, "output_text", None)
124
+ request = str(output_text() if callable(output_text) else "").strip()
125
+ content = [
126
+ {
127
+ "type": "text",
128
+ "text": self.agent.read_prompt(
129
+ "fw.vision_load.md",
130
+ request=request,
131
+ query=str(query or "").strip(),
132
+ ),
133
+ }
134
+ ]
135
+ content.extend(
136
+ {"type": "image_url", "image_url": {"url": path}}
137
+ for path in image_paths
138
+ )
139
+ response, _ = await build_vision_model(self.agent).unified_call(
140
+ messages=[HumanMessage(content=content)],
141
+ )
142
+ if not str(response or "").strip():
143
+ raise RuntimeError("Vision Model returned an empty response.")
144
+ return str(response)
145
+
146
+ def _store_ephemeral_image(self, image: ephemeral_images.EphemeralImage) -> str:
147
+ context_id = self._context_id()
148
+ if not context_id:
149
+ return image.data_url
150
+ source = chat_media.infer_source(image.ref, image.display_name)
151
+ category = chat_media.category_for_source(source)
152
+ saved = chat_media.save_image_base64(
153
+ context_id=context_id,
154
+ data=image.data,
155
+ mime_type=image.mime,
156
+ category=category,
157
+ source=source,
158
+ preferred_name=image.display_name,
159
+ )
160
+ return saved.a0_path
161
+
162
+ def _store_data_url(self, data_url: str, *, preferred_name: str = "") -> str:
163
+ context_id = self._context_id()
164
+ if not context_id:
165
+ return data_url
166
+ source = chat_media.infer_source(data_url, preferred_name)
167
+ category = chat_media.category_for_source(source)
168
+ saved = chat_media.save_image_data_url(
169
+ context_id=context_id,
170
+ data_url=data_url,
171
+ category=category,
172
+ source=source,
173
+ preferred_name=preferred_name,
174
+ )
175
+ return saved.a0_path
176
+
177
+ def _store_local_image(self, path: str, *, preferred_name: str = "") -> str:
178
+ context_id = self._context_id()
179
+ if not context_id:
180
+ return images.to_data_url(path)
181
+ return chat_media.materialize_image_ref(
182
+ context_id=context_id,
183
+ url=path,
184
+ source=chat_media.infer_source(path, preferred_name),
185
+ preferred_name=preferred_name,
186
+ )
187
+
188
+ def _summary(self) -> str:
189
+ loaded = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
190
+ summary = f"Loaded images ({len(self.loaded_paths)}):\n{loaded}"
191
+ if self.skipped_paths:
192
+ summary += (
193
+ f"\n\nSkipped images ({len(self.skipped_paths)}, max {self._get_max_embeds()}):\n"
194
+ + "\n".join(self.skipped_paths)
195
+ )
196
+ return summary
197
+
198
+ @staticmethod
199
+ def _is_data_image_url(value: str) -> bool:
200
+ normalized = str(value or "").strip().lower()
201
+ return normalized.startswith("data:image/") and ";base64," in normalized
202
+
203
+ @classmethod
204
+ def _display_input_path(cls, value: str, index: int) -> str:
205
+ if ephemeral_images.is_ref(value):
206
+ return ephemeral_images.display_ref(value)
207
+ if cls._is_data_image_url(value):
208
+ prefix = value.split(",", 1)[0]
209
+ return f"{prefix},<ephemeral-image-{index}>"
210
+ return value
211
+
212
+ async def after_execution(self, response: Response, **kwargs):
213
+ await super().after_execution(response, **kwargs)
214
+ if self.images_dict and not self.vision_config:
215
+ content = [
216
+ {"type": "image_url", "image_url": {"url": image_path}}
217
+ for image_path in self.images_dict.values()
218
+ ]
219
+ raw_message = history.RawMessage(
220
+ raw_content=content,
221
+ preview="<Image attachments loaded by path>",
222
+ )
223
+ tokens = TOKENS_ESTIMATE * len(content)
224
+ if not parallel_tools.queue_parallel_parent_history(
225
+ self.agent,
226
+ content=raw_message,
227
+ tokens=tokens,
228
+ ):
229
+ self.agent.hist_add_message(
230
+ False,
231
+ content=raw_message,
232
+ tokens=tokens,
233
+ )
tools/vision_load.py.dox.md CHANGED
@@ -1,3 +1,62 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:826f3b31ec512db1d099dee2cc93ab15933e7699bf7749102f4efdcd7be5c374
3
- size 4459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # vision_load.py DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the `vision_load.py` agent tool.
6
+ - This module routes images either into Main model-visible content or through the preset's optional Vision Model.
7
+ - Keep this file-level DOX profile synchronized with `vision_load.py` because this directory is intentionally flat.
8
+
9
+ ## Ownership
10
+
11
+ - `vision_load.py` owns the runtime implementation.
12
+ - `vision_load.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
+ - Classes:
14
+ - `VisionLoad` (`Tool`)
15
+ - `async execute(self, paths, query="", **kwargs) -> Response`
16
+ - `async after_execution(self, response: Response, **kwargs)`
17
+ - Notable constants/configuration names: `TOKENS_ESTIMATE`.
18
+
19
+ ## Runtime Contracts
20
+
21
+ - Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
22
+ - One call may contain multiple paths; a bare string is treated as one path. The Vision Model route sends every selected path in one request and returns one textual capsule.
23
+ - Model configuration exposes a Vision Model only when the effective preset selects that route; otherwise this tool follows Main's native vision path.
24
+ - The public tool contract is route-agnostic and accepts an optional focused `query`. A Vision Model receives both that query and the current user request through `fw.vision_load.md`; direct parallel workers inherit the request from their parent.
25
+ - Native Main vision already retains the query in its authored tool-call transcript, so native raw history remains image-only and does not repeat model-authored instructions as user content.
26
+ - Delegation completes during `execute(...)` so native Responses function output contains the real capsule before `after_execution(...)` persists it.
27
+ - Delegated history contains the text capsule only. Native history contains the tool result followed by one raw message holding all loaded image blocks.
28
+ - In a direct parallel worker, native image content is queued for the parent and promoted immediately after the outer `parallel` result; the disposable worker never owns the only copy of model-visible pixels.
29
+ - Direct parallel workers inherit the parent's model override generically. This tool uses their recorded parent context only to resolve ephemeral refs and durable chat media.
30
+ - `max_embeds` comes from the model that actually receives the images.
31
+ - Native tool-result summaries omit the skipped-images section when no images were skipped; when the limit drops inputs, the section reports the skipped paths and active maximum.
32
+ - Vision Model calls use the selected model's Advanced `kwargs`; this tool does not impose a separate timeout or output-token limit.
33
+ - An empty Vision Model response is reported as an image-analysis error instead of a successful empty capsule.
34
+ - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
35
+ - `VisionLoad` is a `Tool`.
36
+ - `VisionLoad` defines `execute(...)`.
37
+ - Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence, secret handling.
38
+ - Imported dependency areas include: `helpers`, `helpers.tool`, `langchain_core.messages`, `mimetypes`, and `_model_config`.
39
+
40
+ ## Key Concepts
41
+
42
+ - Important called helpers/classes observed in the source: `build_vision_model`, `get_vision_model_config`, `self._get_max_embeds`, `Response`, `self._context_id`, `chat_media.save_image_base64`, `chat_media.save_image_data_url`, `chat_media.materialize_image_ref`, `ephemeral_images.consume_image`, `images.to_data_url`, `history.RawMessage`, `super().after_execution`, `model.unified_call`.
43
+ - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
44
+
45
+ ## Work Guidance
46
+
47
+ - Keep tool output concise, model-readable, and safe for history persistence.
48
+ - Coordinate argument or behavior changes with prompt tool instructions and skill guidance.
49
+ - Respect intervention flow for long-running, external, or user-visible operations.
50
+
51
+ ## Verification
52
+
53
+ - Run targeted tool and prompt-contract tests for changed behavior; smoke-test agent execution when no focused test exists.
54
+ - Related tests observed by source search:
55
+ - `tests/test_browser_agent_regressions.py`
56
+ - `tests/test_host_browser_connector.py`
57
+ - `tests/test_office_desktop_state.py`
58
+ - `tests/test_vision_load_image_refs.py`
59
+
60
+ ## Child DOX Index
61
+
62
+ No child DOX files.
tools/wait.py CHANGED
@@ -1,3 +1,89 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:72989a7fece9f2aa74316f2964c56d996810ff1e08324101f80a599f04cc7fba
3
- size 2930
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import timedelta
3
+ from helpers.tool import Tool, Response
4
+ from helpers.print_style import PrintStyle
5
+ from helpers.wait import managed_wait
6
+ from helpers.localization import Localization
7
+
8
+ class WaitTool(Tool):
9
+
10
+ async def execute(self, **kwargs) -> Response:
11
+ await self.agent.handle_intervention()
12
+
13
+ seconds = self.args.get("seconds", 0)
14
+ minutes = self.args.get("minutes", 0)
15
+ hours = self.args.get("hours", 0)
16
+ days = self.args.get("days", 0)
17
+ until_timestamp_str = self.args.get("until")
18
+
19
+ is_duration_wait = not bool(until_timestamp_str)
20
+
21
+ now = Localization.get().now()
22
+ target_time = None
23
+
24
+ if until_timestamp_str:
25
+ try:
26
+ target_time = Localization.get().localtime_str_to_utc_dt(until_timestamp_str)
27
+ if not target_time:
28
+ raise ValueError(f"Invalid timestamp format: {until_timestamp_str}")
29
+ except ValueError as e:
30
+ return Response(
31
+ message=str(e),
32
+ break_loop=False,
33
+ )
34
+ else:
35
+ wait_duration = timedelta(
36
+ days=int(days),
37
+ hours=int(hours),
38
+ minutes=int(minutes),
39
+ seconds=int(seconds),
40
+ )
41
+ if wait_duration.total_seconds() <= 0:
42
+ return Response(
43
+ message="Wait duration must be positive.",
44
+ break_loop=False,
45
+ )
46
+ target_time = now + wait_duration
47
+
48
+ if target_time <= now:
49
+ return Response(
50
+ message=f"Target time {Localization.get().serialize_datetime(target_time)} is in the past.",
51
+ break_loop=False,
52
+ )
53
+
54
+ PrintStyle.info(f"Waiting until {Localization.get().serialize_datetime(target_time)}...")
55
+
56
+ target_time = await managed_wait(
57
+ agent=self.agent,
58
+ target_time=target_time,
59
+ is_duration_wait=is_duration_wait,
60
+ log=self.log,
61
+ get_heading_callback=self.get_heading
62
+ )
63
+
64
+ if self.log:
65
+ self.log.update(heading=self.get_heading("Done", done=True))
66
+
67
+ message = self.agent.read_prompt(
68
+ "fw.wait_complete.md",
69
+ target_time=Localization.get().serialize_datetime(target_time)
70
+ )
71
+
72
+ return Response(
73
+ message=message,
74
+ break_loop=False,
75
+ )
76
+
77
+ def get_log_object(self):
78
+ return self.agent.context.log.log(
79
+ type="progress",
80
+ heading=self.get_heading(),
81
+ content="",
82
+ kvps=self.args,
83
+ )
84
+
85
+ def get_heading(self, text: str = "", done: bool = False):
86
+ done_icon = " icon://done_all" if done else ""
87
+ if not text:
88
+ text = f"Waiting..."
89
+ return f"icon://timer Wait: {text}{done_icon}"
tools/wait.py.dox.md CHANGED
@@ -1,3 +1,54 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7de7c43d3c40bdf266841d4c554d15e759b883f4b066dd758b4b7afa078558d1
3
- size 2477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # wait.py DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the `wait.py` agent tool.
6
+ - This module lets the agent wait for a managed duration while respecting intervention flow.
7
+ - Keep this file-level DOX profile synchronized with `wait.py` because this directory is intentionally flat.
8
+
9
+ ## Ownership
10
+
11
+ - `wait.py` owns the runtime implementation.
12
+ - `wait.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
+ - Classes:
14
+ - `WaitTool` (`Tool`)
15
+ - `async execute(self, **kwargs) -> Response`
16
+ - `get_log_object(self)`
17
+ - `get_heading(self, text: str=..., done: bool=...)`
18
+
19
+ ## Runtime Contracts
20
+
21
+ - Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
22
+ - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
23
+ - `WaitTool` is a `Tool`.
24
+ - `WaitTool` defines `execute(...)`.
25
+ - Observed side-effect areas: settings/state persistence.
26
+ - Imported dependency areas include: `asyncio`, `datetime`, `helpers.localization`, `helpers.print_style`, `helpers.tool`, `helpers.wait`.
27
+
28
+ ## Key Concepts
29
+
30
+ - Important called helpers/classes observed in the source: `Localization.get.now`, `PrintStyle.info`, `self.agent.read_prompt`, `Response`, `self.agent.context.log.log`, `self.agent.handle_intervention`, `timedelta`, `managed_wait`, `Localization.get.localtime_str_to_utc_dt`, `wait_duration.total_seconds`, `Localization.get.serialize_datetime`, `self.get_heading`, `ValueError`.
31
+ - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
32
+
33
+ ## Work Guidance
34
+
35
+ - Keep tool output concise, model-readable, and safe for history persistence.
36
+ - Coordinate argument or behavior changes with prompt tool instructions and skill guidance.
37
+ - Respect intervention flow for long-running, external, or user-visible operations.
38
+
39
+ ## Verification
40
+
41
+ - Run targeted tool and prompt-contract tests for changed behavior; smoke-test agent execution when no focused test exists.
42
+ - Related tests observed by source search:
43
+ - `tests/email_parser_test.py`
44
+ - `tests/rate_limiter_test.py`
45
+ - `tests/test_api_chat_lifetime.py`
46
+ - `tests/test_browser_agent_regressions.py`
47
+ - `tests/test_chat_compaction.py`
48
+ - `tests/test_default_prompt_budget.py`
49
+ - `tests/test_document_query_plugin.py`
50
+ - `tests/test_download_toast_regressions.py`
51
+
52
+ ## Child DOX Index
53
+
54
+ No child DOX files.
update_reqs.py CHANGED
@@ -1,3 +1,38 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:d6f700fc5dfbb40827732adb650e07b080cf5796443849f3acd1a81be1556968
3
- size 1248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pkg_resources
2
+ import re
3
+
4
+ def get_installed_version(package_name):
5
+ try:
6
+ return pkg_resources.get_distribution(package_name).version
7
+ except pkg_resources.DistributionNotFound:
8
+ return None
9
+
10
+ def update_requirements():
11
+ with open('requirements.txt', 'r') as f:
12
+ requirements = f.readlines()
13
+
14
+ updated_requirements = []
15
+ for req in requirements:
16
+ req = req.strip()
17
+ if not req or req.startswith('#'):
18
+ updated_requirements.append(req)
19
+ continue
20
+
21
+ # Extract package name
22
+ match = re.match(r'^([^=<>]+)==', req)
23
+ if match:
24
+ package_name = match.group(1)
25
+ current_version = get_installed_version(package_name)
26
+ if current_version:
27
+ updated_requirements.append(f'{package_name}=={current_version}')
28
+ else:
29
+ updated_requirements.append(req) # Keep original if package not found
30
+ else:
31
+ updated_requirements.append(req) # Keep original if pattern doesn't match
32
+
33
+ # Write updated requirements
34
+ with open('requirements.txt', 'w') as f:
35
+ f.write('\n'.join(updated_requirements) + '\n')
36
+
37
+ if __name__ == '__main__':
38
+ update_requirements()
usr/skills/.gitkeep CHANGED
@@ -1,3 +1 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
3
- size 1
 
1
+
 
 
webui/AGENTS.md CHANGED
@@ -1,3 +1,60 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:905c7dbc80e8df70a9d275d78506c5f88673fff8a686562d3f4566b69eddc22c
3
- size 7052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WebUI DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the Flask-served Alpine.js WebUI shell, frontend modules, components, CSS, static assets, and vendored browser libraries.
6
+ - Keep the UI coherent with backend APIs, WebSocket state, plugin extension points, and documented frontend patterns.
7
+
8
+ ## Ownership
9
+
10
+ - `splash.html` owns the self-contained cache/bootstrap document served at `/`; `safe.html` owns the self-contained service-worker escape hatch served at `/safe`; `index.html`, `index.js`, and `index.css` define the rendered main UI shell served directly at `/index.html` and `/safe`, and fetched from `/ui/index` for in-place installation at `/`.
11
+ - `components/` owns self-contained Alpine components and component stores.
12
+ - `js/` owns shared frontend modules, API clients, WebSocket clients, stores, extension loaders, and utility code.
13
+ - `css/` owns shared stylesheet modules.
14
+ - `public/` owns first-party static image/icon assets.
15
+ - `vendor/` owns vendored third-party browser libraries.
16
+
17
+ ## Local Contracts
18
+
19
+ - Store-dependent UI must be gated with `x-data` and `x-if="$store.storeName"` before using the store.
20
+ - Use `createStore` from `/js/AlpineStore.js` for frontend stores.
21
+ - Use `openModal(path)` and `closeModal()` from `/js/modals.js` for modal flows.
22
+ - Use `/js/api.js` helpers so CSRF and auth behavior stays consistent.
23
+ - Component tags use `<x-component path="...">`; paths are resolved under `webui/components/` when not already prefixed.
24
+ - Frontend extension breakpoints use `<x-extension id="...">` and are loaded through `/js/extensions.js`.
25
+ - `sw.js` owns same-origin HTML/CSS/JavaScript caching. The self-contained splash fetches `/ui/index` and the gzip-compressed `/ui/asset-bundle` in parallel, installs the versioned worker, waits until the bundle is usable in memory, and then installs the rendered index into the current `/` document without navigation. The worker persists the whole prepared bundle as one Cache Storage response in the background and removes obsolete version caches. Eligible text bodies up to 512 KiB each may be embedded; unknown or runtime-computed text URLs use one ordinary backend request and are cached as the native response when their transferred body is at most 256 KiB. The worker does not parse imports, call a secondary graph endpoint, or perform compression. Worker, cache, bundle, and timeout failures fall through to the already-started backend document or ordinary backend asset request. Media, images, fonts, and manifests use normal browser HTTP caching.
26
+ - `splash.html` keeps its logo, styling, and bootstrap logic self-contained. Before replacing the document, it carries its current overlay markup into the rendered index. `index.html` owns the matching inline critical overlay style and fades that same visual overlay after initial component/extension readiness, with a bounded failure timeout; it must not contain duplicate static overlay markup. Icon-font preloading begins when the index is placed behind the overlay, continues independently behind square transparent placeholders, and must neither compete with the startup bundle nor delay the application reveal. `safe.html` must remain self-contained: its first phase unregisters all service workers for the origin, then replaces itself with the directly rendered `/safe` phase without fetching the bundle or registering a worker. The direct phase repeats unregistration defensively but does not initialize a worker; returning to `/` may install the current worker again. The rendered index removes only the internal safe-mode query marker from `/safe` in browser history. Except for the tiny blocking icon guard stylesheet needed to prevent ligature layout shifts, initial application stylesheets remain non-render-blocking.
27
+ - `js/icons.js` owns the native `<x-icon>` element. First-party WebUI and bundled-plugin markup must use `<x-icon name="lowercase_snake_case"></x-icon>` for static Material Symbols and `:name="expression"` for Alpine-driven names; do not put ligature text inside the element or author new `.material-symbols-outlined` spans. The element uses the single local WOFF2 font rather than per-icon SVG requests, validates names, defaults unlabeled icons to `aria-hidden="true"`, and retains the legacy Material class internally so established selectors continue to apply. The shared vendor CSS constrains both `<x-icon>` and legacy ligature spans to a clipped 1:1 em square and keeps them transparent until the icon face is confirmed after the installed application document finishes parsing. `.material-symbols-outlined` and `.material-icons-outlined` spans remain supported for third-party plugin compatibility but are not the first-party authoring API.
28
+ - Component HTML loaded by the shared loader may include `<title>`, module scripts, body content, and scoped styles; modal content uses the same loader path.
29
+ - Do not bypass WebSocket origin/auth/CSRF assumptions from frontend code.
30
+ - Avoid editing vendored files unless intentionally updating the vendor asset.
31
+ - Startup scripts in `index.html` must be local and non-parser-blocking: use ES modules, `defer`, or `async` for every script with `src`.
32
+ - Baseline main and login pages must load scripts, styles, fonts, and images from same-origin vendored or first-party assets so they remain usable without internet access.
33
+ - Rubik (`--font-family-main`) is the default WebUI text and control font; use the code/mono font tokens only for code, logs, paths, and fixed-width data.
34
+ - Hover, focus, and active border treatments should follow existing neutral border/background patterns; avoid hard-coded blue border highlights unless matching an established specialized surface.
35
+ - Keep transient UI affordances such as Bootstrap tooltips and notification toasts above normal and legacy modal layers while confirmation dialogs remain on top.
36
+
37
+ ## Work Guidance
38
+
39
+ - Put component-specific markup and styles under `components/`; put reusable frontend infrastructure under `js/`; put shared visual primitives under `css/`.
40
+ - Keep UI text and controls consistent with existing components.
41
+ - Use notifications for user-facing success, warning, and error feedback where the app already uses notification flows.
42
+ - Coordinate API payload changes with backend handlers and tests.
43
+
44
+ ## Verification
45
+
46
+ - Run targeted WebUI/frontend tests when available.
47
+ - Manually smoke-test visible UI changes with `python run_ui.py` when behavior cannot be covered by tests.
48
+ - Verify desktop and mobile layout for substantial UI changes.
49
+
50
+ ## Child DOX Index
51
+
52
+ Direct child DOX files:
53
+
54
+ | Child | Scope |
55
+ | --- | --- |
56
+ | [components/AGENTS.md](components/AGENTS.md) | Alpine component HTML, component stores, and component-local styles. |
57
+ | [css/AGENTS.md](css/AGENTS.md) | Shared WebUI stylesheet modules. |
58
+ | [js/AGENTS.md](js/AGENTS.md) | Shared frontend JavaScript modules and client-side infrastructure. |
59
+ | [public/AGENTS.md](public/AGENTS.md) | First-party static WebUI images, icons, splash art, and PWA assets. |
60
+ | [vendor/AGENTS.md](vendor/AGENTS.md) | Vendored third-party browser libraries. |
webui/components/AGENTS.md CHANGED
@@ -1,3 +1,77 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:4b4ee454a09ecc7546e8c759ed222b279208ee9cc5f053beb3e5c0ae352d48c7
3
- size 5902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WebUI Components DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own self-contained Alpine.js components, component stores, and component-local styles.
6
+ - Keep component lifecycle, store registration, and nested component loading consistent.
7
+
8
+ ## Ownership
9
+
10
+ - Each subdirectory owns a UI feature area such as sidebar, settings, projects, notifications, plugins, canvas, or welcome screens.
11
+ - `*-store.js` files own component state and actions.
12
+ - Component HTML files own their markup, local module imports, and local scoped styles.
13
+ - `_examples/` contains reference implementations for component and store shape.
14
+ - Modal components normally live under `modals/<name>/<name>.html`; settings components live under `settings/`.
15
+
16
+ ## Local Contracts
17
+
18
+ - Module imports belong in the component `<head>`.
19
+ - Module scripts must use `type="module"` and import stores before Alpine evaluates bindings.
20
+ - Rendered content belongs in the component `<body>`.
21
+ - Wrap store-dependent content with `x-data` and a `template x-if="$store.name"` gate.
22
+ - A gated `<template>` must contain one root element.
23
+ - Keep component-specific styles in the component `<style>` block; use shared CSS only for primitives that are genuinely reused.
24
+ - Use CSS variables from `index.css` for color, spacing, typography, borders, and transitions.
25
+ - Use `x-create` for per-mount setup and `x-destroy` for cleanup; do not call long-lived store `init()` from `x-init`.
26
+ - Store `init()` must be idempotent and guarded when it registers global listeners, intervals, or one-time data.
27
+ - Store state can be read as `$store.name` in templates and through direct module imports in JavaScript; avoid `window.Alpine.store()` lookups in component code.
28
+ - Use `globalThis.xAttrs(element)` only for explicit parent `<x-component>` attribute inheritance.
29
+ - Use shared API, modal, notification, confirmation, and store helpers instead of ad hoc globals.
30
+ - Author Material Symbols as empty `<x-icon name="lowercase_snake_case"></x-icon>` elements. Bind changing icons through `:name`, keep accessible text on the owning control, and do not add new ligature-text spans or `x-text` icon bindings.
31
+ - Components that use polling directives may use `x-every-second`, `x-every-minute`, or `x-every-hour` only while mounted.
32
+ - Use `$confirmClick` for destructive two-click confirmations where the existing UI pattern fits.
33
+ - Name component files `feature-name.html` and stores `feature-store.js` or `feature-name-store.js`.
34
+
35
+ ## Work Guidance
36
+
37
+ - Keep component stores cohesive and avoid cross-component state mutation unless a shared store owns that state.
38
+ - Prefer nested `<x-component path="...">` for reusable UI pieces.
39
+ - Use absolute imports such as `/components/...` and `/js/...` so loader-generated module URLs resolve reliably.
40
+ - Preserve flex layouts through loader wrappers with `display: contents` on the specific wrapper chain when needed.
41
+ - Use `callJsonApi()` for JSON-in/JSON-out calls and `fetchApi()` for raw fetches that still need CSRF handling.
42
+ - Use `openModal(path)` for modal entry points; modal titles come from component `<title>`.
43
+ - Add modal footers with `data-modal-footer` so `modals.js` can move them to the pinned footer slot.
44
+ - Keep modal footer elements stable after load; conditional footers should not be repeatedly created and destroyed.
45
+ - Use `.modal-floating` only for non-blocking utility panels where page clicks must pass through; do not use it for destructive confirmations, settings, auth, import/export, or required workflows.
46
+ - Do not add `document.addEventListener("alpine:init", ...)` blocks inside component HTML.
47
+ - Do not use legacy `x-teleport` plus `.modal-overlay` modal patterns for new work.
48
+ - Do not introduce a second modal overlay system or manually reshape `.modal-inner`, `.modal-scroll`, or `.modal-footer-slot`.
49
+ - Do not hide fetch, import, or lifecycle errors with broad `.catch(() => null)` handlers; surface failures through notifications or console errors as appropriate.
50
+ - Do not store sensitive modal values in long-lived stores without explicit cleanup on close.
51
+
52
+ ## Verification
53
+
54
+ - Manually exercise the affected component in the WebUI for visible or lifecycle changes.
55
+ - Run targeted tests for settings, plugins, notifications, projects, state sync, or other touched flows when available.
56
+ - For modal changes, verify open, close button, Escape, click-outside, stacked modal, scroll, and pinned footer behavior.
57
+
58
+ ## Child DOX Index
59
+
60
+ Direct child DOX files:
61
+
62
+ | Child | Scope |
63
+ | --- | --- |
64
+ | [_examples/AGENTS.md](_examples/AGENTS.md) | Reference component and store examples. |
65
+ | [canvas/AGENTS.md](canvas/AGENTS.md) | Right-canvas component surface and store. |
66
+ | [chat/AGENTS.md](chat/AGENTS.md) | Chat composer, attachments, queue, navigation, and top-section components. |
67
+ | [dropdown/AGENTS.md](dropdown/AGENTS.md) | Shared dropdown component. |
68
+ | [messages/AGENTS.md](messages/AGENTS.md) | Message rendering helpers, action buttons, process groups, and resize behavior. |
69
+ | [modals/AGENTS.md](modals/AGENTS.md) | Modal component workflows loaded through the shared modal stack. |
70
+ | [notifications/AGENTS.md](notifications/AGENTS.md) | Toasts, notification modal, icons, and notification store. |
71
+ | [plugins/AGENTS.md](plugins/AGENTS.md) | Plugin settings, info, list, execution, and toggle components. |
72
+ | [projects/AGENTS.md](projects/AGENTS.md) | Project creation, selection, edit, secrets, model, skill, and file-structure components. |
73
+ | [settings/AGENTS.md](settings/AGENTS.md) | Settings shell and built-in settings subsections. |
74
+ | [sidebar/AGENTS.md](sidebar/AGENTS.md) | Left sidebar layout, chat/task lists, top actions, and preferences. |
75
+ | [sync/AGENTS.md](sync/AGENTS.md) | WebUI sync status component and store. |
76
+ | [tooltips/AGENTS.md](tooltips/AGENTS.md) | Shared tooltip store and behavior. |
77
+ | [welcome/AGENTS.md](welcome/AGENTS.md) | Welcome screen, discovery cards, banners, and welcome state. |
webui/components/_examples/AGENTS.md CHANGED
@@ -1,3 +1,27 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1b7033487bae6a3700601a3a13ee754d764cae76c0175a4539b12c961557b803
3
- size 624
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Component Examples DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own reference component and store examples for WebUI component authors.
6
+
7
+ ## Ownership
8
+
9
+ - `_example-component.html` owns the component markup example.
10
+ - `_example-store.js` owns the store pattern example.
11
+
12
+ ## Local Contracts
13
+
14
+ - Keep examples aligned with current component loader, Store Gating, and `createStore` patterns.
15
+ - Do not add product behavior or dependencies here.
16
+
17
+ ## Work Guidance
18
+
19
+ - Prefer small examples that demonstrate current conventions clearly.
20
+
21
+ ## Verification
22
+
23
+ - Manually inspect examples after component contract changes.
24
+
25
+ ## Child DOX Index
26
+
27
+ No child DOX files.
webui/components/_examples/_example-component.html CHANGED
@@ -1,3 +1,36 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:004c8364524a2809211ce97806aafbfe357cd1e283a025208f746d5a6ab82f98
3
- size 862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+
3
+ <head>
4
+ <title>Example component or modal</title>
5
+
6
+ <!-- Import the alpine store -->
7
+ <script type="module">
8
+ import { store } from "/components/_examples/_example-store.js";
9
+ </script>
10
+ </head>
11
+
12
+ <body>
13
+
14
+ <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
15
+ <div x-data>
16
+ <template x-if="$store.exampleStore">
17
+
18
+ <!-- Keep in mind that <template> can have only one root element inside -->
19
+ <div>
20
+ <p x-text="$store.exampleStore.example1"></p>
21
+ <p x-text="$store.exampleStore.example2"></p>
22
+ </div>
23
+
24
+ </template>
25
+ </div>
26
+
27
+ <!-- Optional style for the component -->
28
+ <style>
29
+ #example-component {
30
+ width: 100%;
31
+ }
32
+ </style>
33
+
34
+ </body>
35
+
36
+ </html>
webui/components/_examples/_example-store.js CHANGED
@@ -1,3 +1,23 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:bc4574407b5841fdcc2e14f85c0272c3f96770e7c666470d848c6dad0bb66769
3
- size 485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+
3
+ // define the model object holding data and functions
4
+ const model = {
5
+ example1:"Example 1",
6
+ example2:"Example 2",
7
+
8
+ // gets called when the store is created
9
+ init(){
10
+ console.log("Example store initialized");
11
+ },
12
+
13
+ clickHandler(event){
14
+ console.log(event)
15
+ }
16
+
17
+ };
18
+
19
+ // convert it to alpine store
20
+ const store = createStore("_exampleStore", model);
21
+
22
+ // export for use in other files
23
+ export { store };
webui/components/canvas/AGENTS.md CHANGED
@@ -1,3 +1,33 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1cfb44c6adfd734461e1d51790c5b79bf01ea4a67cf269dc63f32b905ae88574
3
- size 1530
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Canvas Components DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the right-canvas component surface and its state.
6
+
7
+ ## Ownership
8
+
9
+ - `right-canvas.html` owns canvas markup.
10
+ - `right-canvas-store.js` owns canvas state, surface registration, and actions.
11
+ - `right-canvas.css` owns canvas-specific styling.
12
+
13
+ ## Local Contracts
14
+
15
+ - Keep registered surfaces compatible with WebUI extension hooks.
16
+ - Preserve responsive layout and avoid overlapping the chat/sidebar shells.
17
+ - Right-canvas rail and tab buttons are explicit canvas entry points above the mobile breakpoint; at mobile widths, keep the rail visible but route non-action surfaces into floating modals instead of the side canvas shell.
18
+ - Keep the right-canvas rail and docked shell hidden while the welcome screen is active; non-action surface opens during welcome must route into floating/modal surfaces instead of docking beside the welcome screen.
19
+ - Preserve docked canvas open state across same-tab reloads with session-scoped state, but do not treat it as durable cross-session UI state.
20
+ - In mobile mode, keep the rail below blocking modal layers and compact it on very narrow screens instead of letting it cover modal content.
21
+ - Keep the canvas shell functional when the instance-level visibility preference hides only the right-canvas rail.
22
+
23
+ ## Work Guidance
24
+
25
+ - Use existing surface and extension helpers before adding new canvas infrastructure.
26
+
27
+ ## Verification
28
+
29
+ - Smoke-test right-canvas open, close, resize, and registered surfaces after changes.
30
+
31
+ ## Child DOX Index
32
+
33
+ No child DOX files.
webui/components/canvas/right-canvas-store.js CHANGED
@@ -1,3 +1,620 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:594000d14b05173a185721add68a5bfc02e6b71de6ec5d72b1e915190f826931
3
- size 19660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
3
+ import { callJsExtensions } from "/js/extensions.js";
4
+ import {
5
+ SURFACE_MODE_DOCKED,
6
+ SURFACE_MODE_FLOATING,
7
+ closeSurfaceGroupModals,
8
+ getRegisteredSurfaces,
9
+ migratePersistedSurfaceState,
10
+ normalizeSurfaceId,
11
+ normalizeSurfaceMode,
12
+ registerSurface as registerSurfaceDefinition,
13
+ } from "/js/surfaces.js";
14
+
15
+ const STORAGE_KEY = "a0.rightCanvas";
16
+ const SESSION_STORAGE_KEY = "a0.rightCanvas.session";
17
+ const DEFAULT_WIDTH = 720;
18
+ const MIN_WIDTH = 0;
19
+ const DESKTOP_BREAKPOINT = 1200;
20
+ const MOBILE_BREAKPOINT = 768;
21
+
22
+ function clamp(value, min, max) {
23
+ return Math.min(Math.max(value, min), max);
24
+ }
25
+
26
+ function viewportWidth() {
27
+ return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
28
+ }
29
+
30
+ function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
31
+ if (value === null || value === undefined || value === "") return fallback;
32
+ const width = Number(value);
33
+ return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
34
+ }
35
+
36
+ function isReloadNavigation() {
37
+ const navigation = performance.getEntriesByType?.("navigation")?.[0];
38
+ if (navigation?.type) return navigation.type === "reload";
39
+ if (!performance.navigation) return false;
40
+ return performance.navigation?.type === performance.navigation?.TYPE_RELOAD;
41
+ }
42
+
43
+ const model = {
44
+ surfaces: [],
45
+ activeSurfaceId: "",
46
+ surfaceModes: {},
47
+ mountedSurfaces: {},
48
+ isOpen: false,
49
+ width: DEFAULT_WIDTH,
50
+ isOverlayMode: false,
51
+ isMobileMode: false,
52
+ _initialized: false,
53
+ _registering: false,
54
+ _rootElement: null,
55
+ _resizeCleanup: null,
56
+ _lastPayloadBySurface: {},
57
+ _restoreOpenRequested: false,
58
+ _restoreOpenTimer: null,
59
+ _restoringSurface: false,
60
+
61
+ async init(element = null) {
62
+ if (element) this._rootElement = element;
63
+ if (this._initialized) {
64
+ this.applyLayoutState();
65
+ this.scheduleRestoreOpenSurface();
66
+ return;
67
+ }
68
+
69
+ this._initialized = true;
70
+ this.restore();
71
+ this.updateLayoutMode();
72
+ this.applyLayoutState();
73
+ globalThis.addEventListener("resize", () => {
74
+ this.updateLayoutMode();
75
+ this.setWidth(this.width, { persist: false });
76
+ this.applyLayoutState();
77
+ });
78
+
79
+ if (!this._registering) {
80
+ this._registering = true;
81
+ await callJsExtensions("surfaces_register", this);
82
+ await callJsExtensions("right_canvas_register_surfaces", this);
83
+ this._registering = false;
84
+ this.ensureActiveSurface();
85
+ this.scheduleRestoreOpenSurface();
86
+ }
87
+ },
88
+
89
+ registerSurface(surface) {
90
+ if (!surface?.id) return;
91
+ const surfaceId = normalizeSurfaceId(surface.id);
92
+ const normalized = {
93
+ title: surface.id,
94
+ icon: "web_asset",
95
+ image: "",
96
+ order: 100,
97
+ canOpen: () => true,
98
+ open: () => {},
99
+ close: () => {},
100
+ modalPath: "",
101
+ actionOnly: false,
102
+ ...surface,
103
+ id: surfaceId,
104
+ };
105
+
106
+ const index = this.surfaces.findIndex((item) => item.id === normalized.id);
107
+ if (index >= 0) {
108
+ this.surfaces.splice(index, 1, normalized);
109
+ } else {
110
+ this.surfaces.push(normalized);
111
+ }
112
+ if (!this.surfaceModes[normalized.id]) {
113
+ this.surfaceModes[normalized.id] = SURFACE_MODE_DOCKED;
114
+ }
115
+ registerSurfaceDefinition(normalized);
116
+ this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
117
+ if (!this._registering) {
118
+ this.ensureActiveSurface();
119
+ }
120
+ },
121
+
122
+ ensureActiveSurface() {
123
+ const panelSurfaces = this.panelSurfaces;
124
+ if (!panelSurfaces.length) {
125
+ this.activeSurfaceId = "";
126
+ return;
127
+ }
128
+ if (!panelSurfaces.some((surface) => surface.id === this.activeSurfaceId)) {
129
+ this.activeSurfaceId = panelSurfaces[0].id;
130
+ }
131
+ },
132
+
133
+ async open(surfaceId = "", payload = {}) {
134
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
135
+ const surface = this.getSurface(targetId);
136
+ if (!surface) {
137
+ return false;
138
+ }
139
+ if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
140
+ return false;
141
+ }
142
+
143
+ this._restoreOpenRequested = false;
144
+ this.cancelRestoreOpenSurface();
145
+
146
+ if (surface.actionOnly) {
147
+ try {
148
+ await surface.open?.(payload || {});
149
+ } catch (error) {
150
+ console.error(`Canvas action ${targetId} failed`, error);
151
+ }
152
+ return true;
153
+ }
154
+
155
+ if (!this.shouldRender()) {
156
+ return await this.openModalSurface(targetId, payload);
157
+ }
158
+
159
+ if (this.isMobileMode) {
160
+ this.activeSurfaceId = targetId;
161
+ this.isOpen = false;
162
+ return await this.openModalSurface(targetId, payload);
163
+ }
164
+
165
+ this.activeSurfaceId = targetId;
166
+ this.markSurfaceMounted(targetId);
167
+ this.isOpen = true;
168
+ this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED, { persist: false });
169
+ this._lastPayloadBySurface[targetId] = payload || {};
170
+ this.persist();
171
+ this.applyLayoutState();
172
+
173
+ try {
174
+ await surface.open?.(payload || {});
175
+ } catch (error) {
176
+ console.error(`Canvas surface ${targetId} failed to open`, error);
177
+ }
178
+ return true;
179
+ },
180
+
181
+ markSurfaceMounted(surfaceId) {
182
+ const targetId = normalizeSurfaceId(surfaceId);
183
+ if (!targetId) return;
184
+ this.mountedSurfaces = {
185
+ ...this.mountedSurfaces,
186
+ [targetId]: true,
187
+ };
188
+ },
189
+
190
+ markSurfaceUnmounted(surfaceId) {
191
+ const targetId = normalizeSurfaceId(surfaceId);
192
+ if (!targetId || !this.mountedSurfaces[targetId]) return;
193
+ const next = { ...this.mountedSurfaces };
194
+ delete next[targetId];
195
+ this.mountedSurfaces = next;
196
+ },
197
+
198
+ mountedSurfaceIds() {
199
+ return Object.entries(this.mountedSurfaces)
200
+ .filter(([, mounted]) => mounted)
201
+ .map(([surfaceId]) => surfaceId);
202
+ },
203
+
204
+ isSurfaceMounted(id) {
205
+ return Boolean(this.mountedSurfaces[normalizeSurfaceId(id)]);
206
+ },
207
+
208
+ isSurfaceRendered(id) {
209
+ return Boolean(this.isOpen && this.isSurfaceMounted(id));
210
+ },
211
+
212
+ isSurfaceVisible(id) {
213
+ const targetId = normalizeSurfaceId(id);
214
+ return Boolean(this.isOpen && this.activeSurfaceId === targetId && this.isSurfaceMounted(targetId));
215
+ },
216
+
217
+ recordSurfaceMode(surfaceId, mode = SURFACE_MODE_DOCKED, options = {}) {
218
+ const targetId = normalizeSurfaceId(surfaceId);
219
+ if (!targetId) return;
220
+ this.surfaceModes = {
221
+ ...this.surfaceModes,
222
+ [targetId]: normalizeSurfaceMode(mode),
223
+ };
224
+ if (options.persist !== false) this.persist();
225
+ },
226
+
227
+ latestSurfaceMode(surfaceId) {
228
+ const targetId = normalizeSurfaceId(surfaceId);
229
+ return normalizeSurfaceMode(this.surfaceModes[targetId]);
230
+ },
231
+
232
+ async openLatest(surfaceId = "", payload = {}) {
233
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
234
+ if (!targetId) return false;
235
+ if (this.isMobileMode) {
236
+ return await this.open(targetId, payload);
237
+ }
238
+ if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
239
+ return await this.openModalSurface(targetId, payload);
240
+ }
241
+ return await this.open(targetId, payload);
242
+ },
243
+
244
+ async close() {
245
+ this._restoreOpenRequested = false;
246
+ this.cancelRestoreOpenSurface();
247
+ this.isOpen = false;
248
+ this.persist();
249
+ this.applyLayoutState();
250
+ return true;
251
+ },
252
+
253
+ async dockSurface(surfaceId, payload = {}) {
254
+ surfaceId = normalizeSurfaceId(surfaceId);
255
+ if (this.isMobileMode) {
256
+ return false;
257
+ }
258
+ const surface = this.getSurface(surfaceId);
259
+ if (!surface) {
260
+ return false;
261
+ }
262
+ const modalPath = payload.modalPath || surface.modalPath || "";
263
+ let handoffStarted = false;
264
+ try {
265
+ await surface.beginDockHandoff?.(payload);
266
+ handoffStarted = true;
267
+
268
+ const closed = await this.closeDockSourceModal(payload, modalPath);
269
+ if (closed === false) {
270
+ await surface.cancelDockHandoff?.(payload);
271
+ return false;
272
+ }
273
+
274
+ const openPayload = { ...payload, source: "modal" };
275
+ delete openPayload.closeSourceModal;
276
+ const opened = await this.open(surfaceId, openPayload);
277
+ await surface.finishDockHandoff?.({ ...openPayload, opened });
278
+ return opened;
279
+ } catch (error) {
280
+ if (handoffStarted) {
281
+ await surface.cancelDockHandoff?.(payload);
282
+ }
283
+ console.error(`Canvas surface ${surfaceId} failed to dock`, error);
284
+ return false;
285
+ }
286
+ },
287
+
288
+ async closeDockSourceModal(payload = {}, modalPath = "") {
289
+ if (typeof payload.closeSourceModal === "function") {
290
+ return (await payload.closeSourceModal()) !== false;
291
+ }
292
+
293
+ const sourceModalPath = payload.sourceModalPath || modalPath;
294
+ if (sourceModalPath || modalPath) {
295
+ const closed = await closeSurfaceGroupModals();
296
+ if (closed === false) return false;
297
+ if (!sourceModalPath || !globalThis.isModalOpen?.(sourceModalPath)) return true;
298
+ }
299
+ if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
300
+ return (await globalThis.closeModal?.(sourceModalPath)) !== false;
301
+ }
302
+ if (modalPath && modalPath !== sourceModalPath && globalThis.isModalOpen?.(modalPath)) {
303
+ return (await globalThis.closeModal?.(modalPath)) !== false;
304
+ }
305
+ return true;
306
+ },
307
+
308
+ async undockSurface(surfaceId = "", payload = {}) {
309
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
310
+ const surface = this.getSurface(targetId);
311
+ const modalPath = payload.modalPath || surface?.modalPath || "";
312
+ if (!surface || !modalPath) return false;
313
+ const openModal = globalThis.ensureModalOpen || globalThis.openModal;
314
+ if (!openModal) return false;
315
+ if (this.activeSurfaceId === targetId) {
316
+ this.isOpen = false;
317
+ this.persist();
318
+ this.applyLayoutState();
319
+ }
320
+ this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
321
+ const modalPromise = openModal(modalPath);
322
+ if (modalPromise?.catch) {
323
+ modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
324
+ }
325
+ return true;
326
+ },
327
+
328
+ async openModalSurface(surfaceId = "", payload = {}) {
329
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
330
+ const surface = this.getSurface(targetId);
331
+ const modalPath = payload.modalPath || surface?.modalPath || "";
332
+ if (!surface || !modalPath) return false;
333
+ const openModal = globalThis.ensureModalOpen || globalThis.openModal;
334
+ if (!openModal) return false;
335
+
336
+ this._restoreOpenRequested = false;
337
+ this.cancelRestoreOpenSurface();
338
+
339
+ if (this.isOpen && this.activeSurfaceId === targetId) {
340
+ this.isOpen = false;
341
+ this.persist();
342
+ this.applyLayoutState();
343
+ }
344
+
345
+ this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
346
+ const modalPromise = openModal(modalPath);
347
+ if (modalPromise?.catch) {
348
+ modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
349
+ }
350
+ return true;
351
+ },
352
+
353
+ async undockActiveSurface() {
354
+ return await this.undockSurface(this.activeSurfaceId);
355
+ },
356
+
357
+ currentSurfaceCanUndock() {
358
+ return Boolean(this.currentSurface()?.modalPath);
359
+ },
360
+
361
+ async toggle(surfaceId = "", payload = {}) {
362
+ const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
363
+ if (this.isOpen && targetId === this.activeSurfaceId) {
364
+ await this.close();
365
+ return false;
366
+ }
367
+ return await this.open(targetId, payload);
368
+ },
369
+
370
+ async toggleCanvas() {
371
+ if (!this.shouldRender()) return false;
372
+ if (this.isMobileMode) {
373
+ return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });
374
+ }
375
+ if (this.isOpen) {
376
+ await this.close();
377
+ return false;
378
+ }
379
+ return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
380
+ },
381
+
382
+ setWidth(px, options = {}) {
383
+ const { persist = true } = options;
384
+ const next = clamp(normalizeWidth(px), MIN_WIDTH, this.maxWidth());
385
+ this.width = next;
386
+ this.applyLayoutState();
387
+ if (persist) this.persist();
388
+ },
389
+
390
+ maxWidth() {
391
+ if (this.isOverlayMode) {
392
+ return Math.max(MIN_WIDTH, viewportWidth() - 44);
393
+ }
394
+
395
+ const container = this._rootElement?.closest(".container");
396
+ const rightPanel = document.getElementById("right-panel");
397
+ const containerRight = container?.getBoundingClientRect().right ?? viewportWidth();
398
+ const panelLeft = rightPanel?.getBoundingClientRect().left ?? 0;
399
+ return Math.max(MIN_WIDTH, Math.floor(containerRight - panelLeft));
400
+ },
401
+
402
+ defaultWidth() {
403
+ return Math.min(DEFAULT_WIDTH, Math.floor(viewportWidth() * 0.45));
404
+ },
405
+
406
+ startResize(event) {
407
+ if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
408
+ if (event.button !== 0) return;
409
+ event.preventDefault();
410
+ this.dispatchResizeEvent("right-canvas-resize-start");
411
+
412
+ const onPointerMove = (moveEvent) => {
413
+ const nextWidth = viewportWidth() - moveEvent.clientX;
414
+ this.setWidth(nextWidth);
415
+ };
416
+ const onPointerUp = () => {
417
+ globalThis.removeEventListener("pointermove", onPointerMove);
418
+ globalThis.removeEventListener("pointerup", onPointerUp);
419
+ globalThis.removeEventListener("pointercancel", onPointerUp);
420
+ document.body.classList.remove("right-canvas-resizing");
421
+ this.persist();
422
+ this.dispatchResizeEvent("right-canvas-resize-end");
423
+ };
424
+
425
+ document.body.classList.add("right-canvas-resizing");
426
+ globalThis.addEventListener("pointermove", onPointerMove);
427
+ globalThis.addEventListener("pointerup", onPointerUp);
428
+ globalThis.addEventListener("pointercancel", onPointerUp);
429
+ },
430
+
431
+ dispatchResizeEvent(name) {
432
+ try {
433
+ globalThis.dispatchEvent(new CustomEvent(name, {
434
+ detail: {
435
+ width: this.width,
436
+ activeSurfaceId: this.activeSurfaceId,
437
+ },
438
+ }));
439
+ } catch {
440
+ // Resize events are an optimization hook for embedded surfaces.
441
+ }
442
+ },
443
+
444
+ cancelRestoreOpenSurface() {
445
+ if (!this._restoreOpenTimer) return;
446
+ globalThis.clearTimeout?.(this._restoreOpenTimer);
447
+ this._restoreOpenTimer = null;
448
+ },
449
+
450
+ scheduleRestoreOpenSurface() {
451
+ if (!this._restoreOpenRequested || this._restoreOpenTimer) return;
452
+ if (!this.shouldRender() || this.isMobileMode) return;
453
+ this._restoreOpenTimer = globalThis.setTimeout?.(() => {
454
+ this._restoreOpenTimer = null;
455
+ this.restoreOpenSurface();
456
+ }, 0) || null;
457
+ },
458
+
459
+ async restoreOpenSurface() {
460
+ if (!this._restoreOpenRequested || this._restoringSurface) return false;
461
+ if (!this.shouldRender() || this.isMobileMode) {
462
+ return false;
463
+ }
464
+
465
+ const targetId = normalizeSurfaceId(this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
466
+ if (!targetId || !this.getSurface(targetId)) {
467
+ return false;
468
+ }
469
+
470
+ this._restoreOpenRequested = false;
471
+ this._restoringSurface = true;
472
+ try {
473
+ return await this.open(targetId, { source: "reload-restore" });
474
+ } finally {
475
+ this._restoringSurface = false;
476
+ }
477
+ },
478
+
479
+ persist() {
480
+ const state = {
481
+ isOpen: this.isOpen,
482
+ activeSurfaceId: this.activeSurfaceId,
483
+ surfaceModes: this.surfaceModes,
484
+ width: this.width,
485
+ };
486
+ try {
487
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
488
+ } catch (error) {
489
+ console.warn("Could not persist right canvas state", error);
490
+ }
491
+ try {
492
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
493
+ } catch (error) {
494
+ console.warn("Could not persist right canvas session state", error);
495
+ }
496
+ },
497
+
498
+ restore() {
499
+ this.width = this.defaultWidth();
500
+ try {
501
+ const saved = migratePersistedSurfaceState(JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"));
502
+ this.isOpen = false;
503
+ this.activeSurfaceId = String(saved.activeSurfaceId || "");
504
+ this.surfaceModes = Object.fromEntries(
505
+ Object.entries(saved.surfaceModes || {}).map(([surfaceId, mode]) => [
506
+ surfaceId,
507
+ normalizeSurfaceMode(mode),
508
+ ]),
509
+ );
510
+ if (Number.isFinite(Number(saved.width))) this.width = Number(saved.width);
511
+ } catch (error) {
512
+ console.warn("Could not restore right canvas state", error);
513
+ }
514
+
515
+ if (isReloadNavigation()) {
516
+ try {
517
+ const savedSession = migratePersistedSurfaceState(
518
+ JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "{}"),
519
+ );
520
+ if (savedSession?.activeSurfaceId) this.activeSurfaceId = String(savedSession.activeSurfaceId);
521
+ if (savedSession?.surfaceModes) {
522
+ this.surfaceModes = Object.fromEntries(
523
+ Object.entries(savedSession.surfaceModes || {}).map(([surfaceId, mode]) => [
524
+ surfaceId,
525
+ normalizeSurfaceMode(mode),
526
+ ]),
527
+ );
528
+ }
529
+ if (Number.isFinite(Number(savedSession?.width))) this.width = Number(savedSession.width);
530
+ this._restoreOpenRequested = Boolean(savedSession?.isOpen && savedSession?.activeSurfaceId);
531
+ } catch (error) {
532
+ console.warn("Could not restore right canvas session state", error);
533
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
534
+ }
535
+ } else {
536
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
537
+ }
538
+
539
+ this.setWidth(this.width, { persist: false });
540
+ },
541
+
542
+ updateLayoutMode() {
543
+ const width = viewportWidth();
544
+ const wasMobileMode = this.isMobileMode;
545
+ this.isOverlayMode = width < DESKTOP_BREAKPOINT;
546
+ this.isMobileMode = width <= MOBILE_BREAKPOINT;
547
+ if (this.isMobileMode) {
548
+ const wasOpen = this.isOpen;
549
+ const mountedIds = this.mountedSurfaceIds();
550
+ this.isOpen = false;
551
+ this.mountedSurfaces = {};
552
+ if ((wasOpen || mountedIds.length > 0) && mountedIds.length > 0) {
553
+ globalThis.setTimeout?.(() => {
554
+ for (const surfaceId of mountedIds) {
555
+ const surface = this.getSurface(surfaceId);
556
+ const payload = this._lastPayloadBySurface[surfaceId] || {};
557
+ surface?.close?.({ ...payload, reason: "mobile" });
558
+ }
559
+ }, 0);
560
+ }
561
+ } else if (wasMobileMode && this.width < MIN_WIDTH) {
562
+ this.width = this.defaultWidth();
563
+ }
564
+ },
565
+
566
+ applyLayoutState() {
567
+ this.updateLayoutMode();
568
+ document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
569
+ document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode && this.shouldRender());
570
+ document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
571
+ document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
572
+ this.scheduleRestoreOpenSurface();
573
+ },
574
+
575
+ widthStyle() {
576
+ if (this.isMobileMode) return "";
577
+ if (!this.isOpen) return "width: 0;";
578
+ if (this.isOverlayMode) {
579
+ return `width: min(${this.width}px, calc(100vw - 44px));`;
580
+ }
581
+ return `width: ${this.width}px;`;
582
+ },
583
+
584
+ getSurface(id) {
585
+ const targetId = normalizeSurfaceId(id);
586
+ return this.surfaces.find((surface) => surface.id === targetId)
587
+ || getRegisteredSurfaces().find((surface) => surface.id === targetId)
588
+ || null;
589
+ },
590
+
591
+ get railSurfaces() {
592
+ return this.surfaces;
593
+ },
594
+
595
+ get panelSurfaces() {
596
+ return this.surfaces.filter((surface) => !surface.actionOnly);
597
+ },
598
+
599
+ currentSurface() {
600
+ return this.getSurface(this.activeSurfaceId);
601
+ },
602
+
603
+ isSurfaceActive(id) {
604
+ return this.activeSurfaceId === normalizeSurfaceId(id);
605
+ },
606
+
607
+ activeTitle() {
608
+ return this.currentSurface()?.title || "Canvas";
609
+ },
610
+
611
+ isWelcomeVisible() {
612
+ return !chatsStore.selected;
613
+ },
614
+
615
+ shouldRender() {
616
+ return !this.isWelcomeVisible();
617
+ },
618
+ };
619
+
620
+ export const store = createStore("rightCanvas", model);
webui/components/canvas/right-canvas.css CHANGED
@@ -1,3 +1,384 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f2075f93cfeb6151b7bf50eaca76aed323ba3fa8a0fc24c491e8f12da19fc405
3
- size 8046
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .container > *,
2
+ #right-panel {
3
+ min-width: 0;
4
+ }
5
+
6
+ #right-panel {
7
+ position: relative;
8
+ }
9
+
10
+ .container > x-component[path="canvas/right-canvas.html"],
11
+ .container > x-component[path="canvas/right-canvas.html"] > div[x-data] {
12
+ display: flex;
13
+ flex: 0 0 auto;
14
+ height: 100%;
15
+ min-width: 0;
16
+ min-height: 0;
17
+ }
18
+
19
+ body.right-canvas-resizing {
20
+ cursor: col-resize;
21
+ user-select: none;
22
+ }
23
+
24
+ .right-canvas {
25
+ --right-canvas-chrome: color-mix(in srgb, var(--color-background) 94%, #000 6%);
26
+ --right-canvas-surface: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
27
+ --right-canvas-border: color-mix(in srgb, var(--color-border) 70%, transparent);
28
+ position: relative;
29
+ z-index: 1200;
30
+ display: flex;
31
+ flex: 0 0 auto;
32
+ height: 100%;
33
+ min-width: 0;
34
+ max-width: none;
35
+ overflow: hidden;
36
+ border-left: 1px solid var(--right-canvas-border);
37
+ background: var(--right-canvas-chrome);
38
+ transition: width 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
39
+ }
40
+
41
+ .right-canvas-rail {
42
+ position: absolute;
43
+ top: 33%;
44
+ right: 100%;
45
+ z-index: 4;
46
+ display: flex;
47
+ flex: 0 0 52px;
48
+ flex-direction: column;
49
+ align-items: center;
50
+ gap: 7px;
51
+ height: auto;
52
+ max-height: min(70vh, calc(100vh - 32px));
53
+ padding: 10px 7px;
54
+ transform: translateY(-50%);
55
+ border-right: 0;
56
+ border-radius: 8px 0 0 8px;
57
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.24);
58
+ background: var(--color-chat-background);
59
+ }
60
+
61
+ .right-canvas-rail-separator {
62
+ width: 24px;
63
+ height: 1px;
64
+ margin: 1px 0 3px;
65
+ background: color-mix(in srgb, var(--right-canvas-border) 76%, transparent);
66
+ }
67
+
68
+ .right-canvas.is-closed {
69
+ width: 0 !important;
70
+ min-width: 0;
71
+ overflow: visible;
72
+ border-left: 0;
73
+ background: transparent;
74
+ }
75
+
76
+ .right-canvas.is-open {
77
+ overflow: visible;
78
+ }
79
+
80
+ .right-canvas.is-closed .right-canvas-shell,
81
+ .right-canvas.is-closed .right-canvas-resize-handle {
82
+ display: none;
83
+ }
84
+
85
+ .right-canvas-rail-button,
86
+ .right-canvas-icon-button,
87
+ .right-canvas-tab {
88
+ appearance: none;
89
+ border: 1px solid transparent;
90
+ color: var(--color-text);
91
+ font: inherit;
92
+ cursor: pointer;
93
+ }
94
+
95
+ .right-canvas-rail-button,
96
+ .right-canvas-icon-button {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ width: 34px;
101
+ height: 34px;
102
+ min-width: 34px;
103
+ min-height: 34px;
104
+ padding: 0;
105
+ border-radius: 7px;
106
+ background: transparent;
107
+ opacity: 0.76;
108
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
109
+ }
110
+
111
+ .right-canvas-toggle-button {
112
+ color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
113
+ }
114
+
115
+ .right-canvas-rail-button:hover,
116
+ .right-canvas-icon-button:hover,
117
+ .right-canvas-rail-button.is-active {
118
+ opacity: 1;
119
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--right-canvas-border));
120
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
121
+ }
122
+
123
+ .right-canvas-rail-button .material-symbols-outlined,
124
+ .right-canvas-icon-button .material-symbols-outlined {
125
+ font-size: 19px;
126
+ }
127
+
128
+ .right-canvas-surface-image {
129
+ display: block;
130
+ width: 22px;
131
+ height: 22px;
132
+ border-radius: 6px;
133
+ object-fit: cover;
134
+ }
135
+
136
+ .right-canvas-shell {
137
+ display: flex;
138
+ flex: 1 1 auto;
139
+ min-width: 0;
140
+ min-height: 0;
141
+ flex-direction: column;
142
+ }
143
+
144
+ .right-canvas-header {
145
+ position: relative;
146
+ z-index: 10;
147
+ display: grid;
148
+ grid-template-columns: minmax(0, 1fr) auto;
149
+ align-items: center;
150
+ gap: 8px;
151
+ min-height: 45px;
152
+ padding: 7px 8px 0 10px;
153
+ border-bottom: 1px solid var(--right-canvas-border);
154
+ background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
155
+ overflow: visible;
156
+ }
157
+
158
+ .right-canvas-tabs {
159
+ display: flex;
160
+ align-items: end;
161
+ gap: 4px;
162
+ min-width: 0;
163
+ overflow-x: auto;
164
+ overflow-y: hidden;
165
+ scrollbar-width: thin;
166
+ }
167
+
168
+ .right-canvas-tabs::-webkit-scrollbar {
169
+ height: 4px;
170
+ }
171
+
172
+ .right-canvas-tabs::-webkit-scrollbar-track {
173
+ background: transparent;
174
+ }
175
+
176
+ .right-canvas-tabs::-webkit-scrollbar-thumb {
177
+ background: color-mix(in srgb, var(--color-border) 76%, transparent);
178
+ border-radius: 999px;
179
+ }
180
+
181
+ .right-canvas-tab {
182
+ display: inline-flex;
183
+ flex: 0 1 auto;
184
+ align-items: center;
185
+ gap: 7px;
186
+ min-width: 0;
187
+ padding: 0 10px;
188
+ background: transparent;
189
+ opacity: 0.4;
190
+ white-space: nowrap;
191
+ transition: border-color 0.16s ease, background-color 0.16s ease, opacity 0.16s ease;
192
+ }
193
+
194
+ .right-canvas-tab:hover,
195
+ .right-canvas-tab.is-active {
196
+ opacity: 1;
197
+ }
198
+
199
+ .right-canvas-tab .material-symbols-outlined {
200
+ flex: 0 0 auto;
201
+ font-size: 18px;
202
+ }
203
+
204
+ .right-canvas-tab .right-canvas-surface-image {
205
+ flex: 0 0 auto;
206
+ width: 18px;
207
+ height: 18px;
208
+ border-radius: 5px;
209
+ }
210
+
211
+ .right-canvas-tab-label {
212
+ min-width: 0;
213
+ overflow: hidden;
214
+ text-overflow: ellipsis;
215
+ font-size: 0.84rem;
216
+ font-weight: 650;
217
+ }
218
+
219
+ .right-canvas-toolbar {
220
+ position: relative;
221
+ z-index: 11;
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 5px;
225
+ padding-bottom: 6px;
226
+ overflow: visible;
227
+ }
228
+
229
+ .right-canvas-panels {
230
+ position: relative;
231
+ z-index: 1;
232
+ display: flex;
233
+ flex: 1 1 auto;
234
+ min-width: 0;
235
+ min-height: 0;
236
+ overflow: hidden;
237
+ background: var(--right-canvas-surface);
238
+ }
239
+
240
+ .right-canvas-panels > x-extension {
241
+ display: contents;
242
+ }
243
+
244
+ .right-canvas-panels > x-extension > x-component {
245
+ display: contents;
246
+ }
247
+
248
+ .right-canvas-surface-panel {
249
+ position: absolute;
250
+ inset: 0;
251
+ display: flex;
252
+ flex: 0 0 auto;
253
+ width: 100%;
254
+ height: 100%;
255
+ min-width: 0;
256
+ min-height: 0;
257
+ overflow: hidden;
258
+ opacity: 0;
259
+ pointer-events: none;
260
+ z-index: 0;
261
+ }
262
+
263
+ .right-canvas-surface-panel.is-active {
264
+ opacity: 1;
265
+ pointer-events: auto;
266
+ z-index: 2;
267
+ }
268
+
269
+ .right-canvas-surface-panel > x-component,
270
+ .right-canvas-surface-panel > x-component > div[x-data] {
271
+ display: flex;
272
+ flex: 1 1 auto;
273
+ width: 100%;
274
+ height: 100%;
275
+ min-width: 0;
276
+ min-height: 0;
277
+ }
278
+
279
+ .right-canvas-empty-state {
280
+ display: grid;
281
+ flex: 1 1 auto;
282
+ place-items: center;
283
+ align-content: center;
284
+ gap: 8px;
285
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
286
+ }
287
+
288
+ .right-canvas-empty-state .material-symbols-outlined {
289
+ font-size: 28px;
290
+ }
291
+
292
+ .right-canvas-resize-handle {
293
+ position: absolute;
294
+ top: 0;
295
+ bottom: 0;
296
+ left: -3px;
297
+ z-index: 3;
298
+ width: 7px;
299
+ cursor: col-resize;
300
+ }
301
+
302
+ body.right-canvas-overlay-mode .right-canvas {
303
+ position: fixed;
304
+ top: 0;
305
+ right: 0;
306
+ bottom: 0;
307
+ height: 100%;
308
+ max-width: calc(100vw - 44px);
309
+ box-shadow: -16px 0 38px rgba(0, 0, 0, 0.34);
310
+ transform: translateX(100%);
311
+ }
312
+
313
+ body.right-canvas-overlay-mode .right-canvas.is-open {
314
+ transform: translateX(0);
315
+ }
316
+
317
+ body.right-canvas-overlay-mode .right-canvas-resize-handle {
318
+ display: none;
319
+ }
320
+
321
+ body.right-canvas-mobile-mode .right-canvas {
322
+ position: fixed;
323
+ top: 0;
324
+ right: 0;
325
+ bottom: 0;
326
+ z-index: 3400;
327
+ display: flex !important;
328
+ width: 0 !important;
329
+ min-width: 0;
330
+ height: 100%;
331
+ overflow: visible;
332
+ transform: none;
333
+ border-left: 0;
334
+ background: transparent;
335
+ box-shadow: none;
336
+ pointer-events: none;
337
+ }
338
+
339
+ body.right-canvas-mobile-mode .right-canvas.is-open {
340
+ transform: none;
341
+ }
342
+
343
+ body.right-canvas-mobile-mode .right-canvas-rail {
344
+ position: fixed;
345
+ top: 50%;
346
+ right: max(0px, env(safe-area-inset-right));
347
+ display: flex;
348
+ transform: translateY(-50%);
349
+ pointer-events: auto;
350
+ }
351
+
352
+ body.right-canvas-mobile-mode .right-canvas-shell,
353
+ body.right-canvas-mobile-mode .right-canvas-resize-handle {
354
+ display: none !important;
355
+ }
356
+
357
+ @media (max-width: 420px) {
358
+ body.right-canvas-mobile-mode .right-canvas-rail {
359
+ gap: 5px;
360
+ padding: 8px 6px;
361
+ transform: translateY(-50%) scale(0.92);
362
+ transform-origin: right center;
363
+ box-shadow: -8px 0 24px rgba(0, 0, 0, 0.22);
364
+ }
365
+
366
+ body.right-canvas-mobile-mode .right-canvas-rail-button,
367
+ body.right-canvas-mobile-mode .right-canvas-icon-button {
368
+ width: 32px;
369
+ height: 32px;
370
+ min-width: 32px;
371
+ min-height: 32px;
372
+ }
373
+
374
+ body.right-canvas-mobile-mode .right-canvas-rail-button .material-symbols-outlined,
375
+ body.right-canvas-mobile-mode .right-canvas-icon-button .material-symbols-outlined {
376
+ font-size: 18px;
377
+ }
378
+ }
379
+
380
+ @media (max-width: 480px) {
381
+ .right-canvas-tab-label {
382
+ display: none;
383
+ }
384
+ }
webui/components/canvas/right-canvas.html CHANGED
@@ -1,3 +1,132 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:325a3338a45d7df92b147f236725c639834a12c4ab1ee0ae603c4ede9e8dd624
3
- size 5617
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/components/canvas/right-canvas-store.js";
5
+ import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <div x-data>
10
+ <template x-if="$store.rightCanvas">
11
+ <aside
12
+ id="right-canvas"
13
+ class="right-canvas"
14
+ :class="{
15
+ 'is-open': $store.rightCanvas.isOpen,
16
+ 'is-closed': !$store.rightCanvas.isOpen,
17
+ 'is-overlay': $store.rightCanvas.isOverlayMode,
18
+ 'is-mobile': $store.rightCanvas.isMobileMode
19
+ }"
20
+ :style="$store.rightCanvas.widthStyle()"
21
+ x-show="$store.rightCanvas.shouldRender()"
22
+ x-init="$store.rightCanvas.init($el)"
23
+ x-effect="$store.rightCanvas.applyLayoutState()"
24
+ aria-label="Universal Canvas"
25
+ >
26
+ <div class="right-canvas-resize-handle" title="Resize canvas" @pointerdown="$store.rightCanvas.startResize($event)"></div>
27
+
28
+ <nav class="right-canvas-rail" aria-label="Canvas surfaces" x-show="$store.preferences.isUiControlVisible('rightCanvasRail')">
29
+ <button
30
+ type="button"
31
+ class="right-canvas-rail-button right-canvas-toggle-button"
32
+ :class="{ 'is-active': $store.rightCanvas.isOpen }"
33
+ :title="$store.rightCanvas.isOpen ? 'Hide canvas' : 'Show canvas'"
34
+ :aria-label="$store.rightCanvas.isOpen ? 'Hide canvas' : 'Show canvas'"
35
+ :aria-pressed="$store.rightCanvas.isOpen.toString()"
36
+ @click="$store.rightCanvas.toggleCanvas()"
37
+ >
38
+ <x-icon name="dock_to_right"></x-icon>
39
+ </button>
40
+
41
+ <div class="right-canvas-rail-separator" aria-hidden="true"></div>
42
+
43
+ <template x-for="surface in $store.rightCanvas.railSurfaces" :key="surface.id">
44
+ <button
45
+ type="button"
46
+ class="right-canvas-rail-button"
47
+ :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) && $store.rightCanvas.isOpen }"
48
+ :title="surface.title"
49
+ :aria-label="surface.title"
50
+ @click="$store.rightCanvas.open(surface.id)"
51
+ >
52
+ <template x-if="surface.image">
53
+ <img class="right-canvas-surface-image" :src="surface.image" alt="" aria-hidden="true">
54
+ </template>
55
+ <template x-if="!surface.image">
56
+ <x-icon :name="surface.icon"></x-icon>
57
+ </template>
58
+ </button>
59
+ </template>
60
+ </nav>
61
+
62
+ <section class="right-canvas-shell">
63
+ <x-extension id="right-canvas-shell-start"></x-extension>
64
+
65
+ <header class="right-canvas-header">
66
+ <div class="right-canvas-tabs" role="tablist" aria-label="Canvas tabs">
67
+ <x-extension id="right-canvas-tabs-start"></x-extension>
68
+ <template x-for="surface in $store.rightCanvas.panelSurfaces" :key="surface.id">
69
+ <button
70
+ type="button"
71
+ class="right-canvas-tab"
72
+ role="tab"
73
+ :aria-selected="$store.rightCanvas.isSurfaceActive(surface.id).toString()"
74
+ :title="surface.title"
75
+ :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) }"
76
+ @click="$store.rightCanvas.open(surface.id)"
77
+ >
78
+ <template x-if="surface.image">
79
+ <img class="right-canvas-surface-image" :src="surface.image" alt="" aria-hidden="true">
80
+ </template>
81
+ <template x-if="!surface.image">
82
+ <x-icon aria-hidden="true" :name="surface.icon"></x-icon>
83
+ </template>
84
+ <span class="right-canvas-tab-label" x-text="surface.title"></span>
85
+ </button>
86
+ </template>
87
+ <x-extension id="right-canvas-tabs-end"></x-extension>
88
+ </div>
89
+
90
+ <div class="right-canvas-toolbar" aria-label="Canvas toolbar">
91
+ <x-extension id="right-canvas-toolbar-start"></x-extension>
92
+ <button
93
+ type="button"
94
+ class="right-canvas-icon-button"
95
+ title="Open as window"
96
+ aria-label="Open as window"
97
+ x-show="$store.rightCanvas.currentSurfaceCanUndock()"
98
+ @click="$store.rightCanvas.undockActiveSurface()"
99
+ >
100
+ <x-icon name="open_in_new"></x-icon>
101
+ </button>
102
+ <button
103
+ type="button"
104
+ class="right-canvas-icon-button right-canvas-close-button"
105
+ title="Close canvas"
106
+ aria-label="Close canvas"
107
+ @click="$store.rightCanvas.close()"
108
+ >
109
+ <x-icon name="close"></x-icon>
110
+ </button>
111
+ <x-extension id="right-canvas-toolbar-end"></x-extension>
112
+ </div>
113
+ </header>
114
+
115
+ <main class="right-canvas-panels">
116
+ <x-extension id="right-canvas-panels"></x-extension>
117
+ <template x-if="$store.rightCanvas.panelSurfaces.length === 0">
118
+ <div class="right-canvas-empty-state">
119
+ <x-extension id="right-canvas-empty-state"></x-extension>
120
+ <x-icon name="space_dashboard"></x-icon>
121
+ <span>Canvas</span>
122
+ </div>
123
+ </template>
124
+ </main>
125
+
126
+ <x-extension id="right-canvas-shell-end"></x-extension>
127
+ </section>
128
+ </aside>
129
+ </template>
130
+ </div>
131
+ </body>
132
+ </html>
webui/components/chat/AGENTS.md CHANGED
@@ -1,3 +1,47 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:145d134096c801ba7884cdf7ca6a494950af6f4f9afbb704ac5f97e7d7eac5ac
3
- size 3545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chat Components DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own chat composer, attachments, message queue, navigation, and top-section component groups.
6
+
7
+ ## Ownership
8
+
9
+ - `input/` owns composer input, progress, banners, and bottom action bars.
10
+ - `attachments/` owns drag/drop and attachment preview UI.
11
+ - `message-queue/` owns queued message display and store state.
12
+ - `navigation/` owns chat navigation state.
13
+ - `top-section/` owns chat header/top area.
14
+ - `model-gate-store.js` and `model-setup-gate.html` own the deferred in-thread model setup gate.
15
+
16
+ ## Local Contracts
17
+
18
+ - Preserve Store Gating for all store-backed chat components.
19
+ - Use shared API, WebSocket, notification, and attachment helpers where available.
20
+ - Do not bypass CSRF or WebSocket state-sync expectations.
21
+ - The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
22
+ - Text-only and attachment sends must render the first user turn immediately with the request message ID so the backend log merges into the same row.
23
+ - Unsent composer text is kept as a separate browser-session draft for each selected chat and restored when switching contexts; a Welcome-screen prompt must follow the chat created for its first send.
24
+ - Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
25
+ - Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
26
+ - While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
27
+ - The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and model-preset editor modals; do not duplicate provider/model/key forms inline.
28
+ - A connected OAuth account without Main/Utility model selection is its own gate state; route to model configuration and do not select models automatically.
29
+ - Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
30
+ - The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
31
+ - While the selected context is running, an empty composer makes the primary button stop the active run; typed text still adds to the queue, and Enter with an empty composer still sends queued messages.
32
+ - Chat navigation controls must cross virtual message-window boundaries; top and bottom target the full cached history rather than only the mounted DOM slice.
33
+ - The page-wide attachment drop overlay must activate only for external file drags so internal WebUI drag interactions keep their own targets.
34
+
35
+ ## Work Guidance
36
+
37
+ - Keep composer and attachment changes responsive across desktop and mobile.
38
+ - Coordinate payload changes with backend chat, upload, and WebSocket handlers.
39
+
40
+ ## Verification
41
+
42
+ - Smoke-test sending, queued messages, attachments, drag/drop, and navigation after visible changes.
43
+ - Smoke-test the unconfigured first-send gate and automatic dispatch after model setup when touching model setup or send interception.
44
+
45
+ ## Child DOX Index
46
+
47
+ No child DOX files.
webui/components/chat/attachments/attachmentsStore.js CHANGED
@@ -1,3 +1,511 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:58ee9c5c4f3edd414df6a73c646170e88069cd365820df9f8de13646bbdcbf74
3
- size 14540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { fetchApi } from "/js/api.js";
3
+ import { store as imageViewerStore } from "../../modals/image-viewer/image-viewer-store.js";
4
+
5
+ const model = {
6
+ // State properties
7
+ attachments: [],
8
+ hasAttachments: false,
9
+ dragDropOverlayVisible: false,
10
+
11
+ async init() {
12
+ await this.initialize();
13
+ },
14
+
15
+ // Initialize the store
16
+ async initialize() {
17
+ // Setup event listeners for drag and drop
18
+ this.setupDragDropHandlers();
19
+ // Setup paste event listener for clipboard images
20
+ this.setupPasteHandler();
21
+ },
22
+
23
+ // Basic attachment management methods
24
+ addAttachment(attachment) {
25
+ // Validate for duplicates
26
+ if (this.validateDuplicates(attachment)) {
27
+ this.attachments.push(attachment);
28
+ this.updateAttachmentState();
29
+ }
30
+ },
31
+
32
+ removeAttachment(index) {
33
+ if (index >= 0 && index < this.attachments.length) {
34
+ this.attachments.splice(index, 1);
35
+ this.updateAttachmentState();
36
+ }
37
+ },
38
+
39
+ clearAttachments() {
40
+ this.attachments = [];
41
+ this.updateAttachmentState();
42
+ },
43
+
44
+ validateDuplicates(newAttachment) {
45
+ // Check if attachment already exists based on name and size
46
+ const isDuplicate = this.attachments.some(
47
+ (existing) =>
48
+ existing.name === newAttachment.name &&
49
+ existing.file &&
50
+ newAttachment.file &&
51
+ existing.file.size === newAttachment.file.size
52
+ );
53
+ return !isDuplicate;
54
+ },
55
+
56
+ updateAttachmentState() {
57
+ this.hasAttachments = this.attachments.length > 0;
58
+ },
59
+
60
+ // Drag drop overlay control methods
61
+ showDragDropOverlay() {
62
+ this.dragDropOverlayVisible = true;
63
+ },
64
+
65
+ hideDragDropOverlay() {
66
+ this.dragDropOverlayVisible = false;
67
+ },
68
+
69
+ isExternalFileDrag(event) {
70
+ return Array.from(event?.dataTransfer?.types || []).includes("Files");
71
+ },
72
+
73
+ // Setup drag and drop event handlers
74
+ setupDragDropHandlers() {
75
+ console.log("Setting up drag and drop handlers...");
76
+ let dragCounter = 0;
77
+
78
+ // Prevent default drag behaviors
79
+ ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
80
+ document.addEventListener(
81
+ eventName,
82
+ (e) => {
83
+ if (!this.isExternalFileDrag(e)) return;
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ },
87
+ false
88
+ );
89
+ });
90
+
91
+ // Handle drag enter
92
+ document.addEventListener(
93
+ "dragenter",
94
+ (e) => {
95
+ if (!this.isExternalFileDrag(e)) return;
96
+ console.log("Drag enter detected");
97
+ dragCounter++;
98
+ if (dragCounter === 1) {
99
+ console.log("Showing drag drop overlay");
100
+ this.showDragDropOverlay();
101
+ }
102
+ },
103
+ false
104
+ );
105
+
106
+ // Handle drag leave
107
+ document.addEventListener(
108
+ "dragleave",
109
+ (e) => {
110
+ if (!this.isExternalFileDrag(e) && dragCounter === 0) return;
111
+ dragCounter--;
112
+ if (dragCounter === 0) {
113
+ this.hideDragDropOverlay();
114
+ }
115
+ },
116
+ false
117
+ );
118
+
119
+ // Handle drop
120
+ document.addEventListener(
121
+ "drop",
122
+ async (e) => {
123
+ if (!this.isExternalFileDrag(e)) return;
124
+ const dataTransfer = e.dataTransfer;
125
+ console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
126
+ dragCounter = 0;
127
+ this.hideDragDropOverlay();
128
+
129
+ let files = [];
130
+ try {
131
+ files = await this.getDroppedFiles(dataTransfer);
132
+ } catch (error) {
133
+ console.error("Failed to read dropped files:", error);
134
+ files = Array.from(dataTransfer?.files || []);
135
+ }
136
+ this.handleFiles(files);
137
+ },
138
+ false
139
+ );
140
+ },
141
+
142
+ // Setup paste event handler for clipboard images
143
+ setupPasteHandler() {
144
+ console.log("Setting up paste handler...");
145
+ document.addEventListener("paste", (e) => {
146
+ // console.log("Paste event detected, target:", e.target.id);
147
+ if (
148
+ e.target.id !== "chat-input" &&
149
+ e.target.id !== "full-screen-input"
150
+ ) return;
151
+
152
+ const items = e.clipboardData.items;
153
+ let imageFound = false;
154
+ console.log("Checking clipboard items:", items.length);
155
+
156
+ // First, check if there are any images in the clipboard
157
+ for (let i = 0; i < items.length; i++) {
158
+ const item = items[i];
159
+ if (item.type.indexOf("image") !== -1) {
160
+ imageFound = true;
161
+ const blob = item.getAsFile();
162
+ if (blob) {
163
+ e.preventDefault(); // Prevent default paste behavior for images
164
+ this.handleClipboardImage(blob);
165
+ console.log("Image detected in clipboard, processing...");
166
+ }
167
+ break; // Only handle the first image found
168
+ }
169
+ }
170
+
171
+ // If no images found and we're in an input field, let normal text paste happen
172
+ if (
173
+ !imageFound &&
174
+ (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
175
+ ) {
176
+ console.log(
177
+ "No images in clipboard, allowing normal text paste in input field"
178
+ );
179
+ return;
180
+ }
181
+
182
+ // If no images found and not in input field, do nothing
183
+ if (!imageFound) {
184
+ console.log("No images in clipboard");
185
+ }
186
+ });
187
+ },
188
+
189
+ // Handle clipboard image pasting
190
+ async handleClipboardImage(blob) {
191
+ try {
192
+ // Generate unique filename
193
+ const guid = this.generateGUID();
194
+ const filename = `clipboard-${guid}.png`;
195
+
196
+ // Create file object from blob
197
+ const file = new File([blob], filename, { type: "image/png" });
198
+
199
+ // Create attachment object
200
+ const attachment = {
201
+ file: file,
202
+ type: "image",
203
+ name: filename,
204
+ extension: "png",
205
+ displayInfo: this.getAttachmentDisplayInfo(file),
206
+ };
207
+
208
+ // Read as data URL for preview
209
+ const reader = new FileReader();
210
+ reader.onload = (e) => {
211
+ attachment.url = e.target.result;
212
+ this.addAttachment(attachment);
213
+ };
214
+ reader.readAsDataURL(file);
215
+
216
+ // Show success feedback
217
+ console.log("Clipboard image pasted successfully:", filename);
218
+ } catch (error) {
219
+ console.error("Failed to handle clipboard image:", error);
220
+ }
221
+ },
222
+
223
+ // Update handleFileUpload to use the attachments store
224
+ handleFileUpload(event) {
225
+ const files = event.target.files;
226
+ this.handleFiles(files);
227
+ event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time
228
+ },
229
+
230
+ async getDroppedFiles(dataTransfer) {
231
+ const items = Array.from(dataTransfer?.items || []);
232
+ const entries = items
233
+ .map((item) =>
234
+ typeof item.webkitGetAsEntry === "function"
235
+ ? item.webkitGetAsEntry()
236
+ : null
237
+ )
238
+ .filter(Boolean);
239
+
240
+ if (!entries.length) {
241
+ return Array.from(dataTransfer?.files || []);
242
+ }
243
+
244
+ const nested = await Promise.all(
245
+ entries.map((entry) => this.readEntryFiles(entry))
246
+ );
247
+ const files = nested.flat();
248
+ return files.length ? files : Array.from(dataTransfer?.files || []);
249
+ },
250
+
251
+ async readEntryFiles(entry) {
252
+ if (entry.isFile) {
253
+ return await new Promise((resolve, reject) => {
254
+ entry.file(
255
+ (file) => resolve([file]),
256
+ (error) => reject(error)
257
+ );
258
+ });
259
+ }
260
+
261
+ if (!entry.isDirectory) return [];
262
+
263
+ const reader = entry.createReader();
264
+ const childEntries = await this.readAllDirectoryEntries(reader);
265
+ const nested = await Promise.all(
266
+ childEntries.map((child) => this.readEntryFiles(child))
267
+ );
268
+ return nested.flat();
269
+ },
270
+
271
+ async readAllDirectoryEntries(reader) {
272
+ const entries = [];
273
+
274
+ return await new Promise((resolve, reject) => {
275
+ const readBatch = () => {
276
+ reader.readEntries(
277
+ (batch) => {
278
+ if (!batch.length) {
279
+ resolve(entries);
280
+ return;
281
+ }
282
+ entries.push(...batch);
283
+ readBatch();
284
+ },
285
+ (error) => reject(error)
286
+ );
287
+ };
288
+
289
+ readBatch();
290
+ });
291
+ },
292
+
293
+ // File handling logic (moved from index.js)
294
+ handleFiles(files) {
295
+ const fileList = Array.from(files || []);
296
+ console.log("handleFiles called with", fileList.length, "files");
297
+ fileList.forEach((file) => {
298
+ if (!file?.name) return;
299
+
300
+ console.log("Processing file:", file.name, file.type);
301
+ const ext = file.name.split(".").pop().toLowerCase();
302
+ const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(
303
+ ext
304
+ );
305
+
306
+ const attachment = {
307
+ file: file,
308
+ type: isImage ? "image" : "file",
309
+ name: file.name,
310
+ extension: ext,
311
+ displayInfo: this.getAttachmentDisplayInfo(file),
312
+ };
313
+
314
+ if (isImage) {
315
+ // Read image as data URL for preview
316
+ const reader = new FileReader();
317
+ reader.onload = (e) => {
318
+ attachment.url = e.target.result;
319
+ this.addAttachment(attachment);
320
+ };
321
+ reader.readAsDataURL(file);
322
+ } else {
323
+ // For non-image files, add directly
324
+ this.addAttachment(attachment);
325
+ }
326
+ });
327
+ },
328
+
329
+ // Get attachments for sending message
330
+ getAttachmentsForSending() {
331
+ return this.attachments.map((attachment) => {
332
+ if (attachment.type === "image") {
333
+ return {
334
+ ...attachment,
335
+ url: URL.createObjectURL(attachment.file),
336
+ };
337
+ } else {
338
+ return {
339
+ ...attachment,
340
+ };
341
+ }
342
+ });
343
+ },
344
+
345
+ // Generate server-side API URL for file (for device sync)
346
+ getServerImgUrl(filename) {
347
+ return `/api/image_get?path=/a0/usr/uploads/${encodeURIComponent(filename)}`;
348
+ },
349
+
350
+ getServerFileUrl(filename) {
351
+ return `/a0/usr/uploads/${encodeURIComponent(filename)}`;
352
+ },
353
+
354
+ // Check if file is an image based on extension
355
+ isImageFile(filename) {
356
+ const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
357
+ const extension = filename.split(".").pop().toLowerCase();
358
+ return imageExtensions.includes(extension);
359
+ },
360
+
361
+ // Get attachment preview URL (server URL for persistence, blob URL for current session)
362
+ getAttachmentPreviewUrl(attachment) {
363
+ // If attachment has a name and we're dealing with a server-stored file
364
+ if (typeof attachment === "string") {
365
+ // attachment is just a filename (from loaded chat)
366
+ return this.getServerImgUrl(attachment);
367
+ } else if (attachment.name && attachment.file) {
368
+ // attachment is an object from current session
369
+ if (attachment.type === "image") {
370
+ // For images, use blob URL for current session preview
371
+ return attachment.url || URL.createObjectURL(attachment.file);
372
+ } else {
373
+ // For non-image files, use server URL to get appropriate icon
374
+ return this.getServerImgUrl(attachment.name);
375
+ }
376
+ }
377
+ return null;
378
+ },
379
+
380
+ getFilePreviewUrl(filename) {
381
+ const extension = filename.split(".").pop().toLowerCase();
382
+ const types = {
383
+ // Archive files
384
+ zip: "archive",
385
+ rar: "archive",
386
+ "7z": "archive",
387
+ tar: "archive",
388
+ gz: "archive",
389
+ // Document files
390
+ pdf: "document",
391
+ doc: "document",
392
+ docx: "document",
393
+ txt: "document",
394
+ rtf: "document",
395
+ odt: "document",
396
+ // Code files
397
+ py: "code",
398
+ js: "code",
399
+ html: "code",
400
+ css: "code",
401
+ json: "code",
402
+ xml: "code",
403
+ md: "code",
404
+ yml: "code",
405
+ yaml: "code",
406
+ sql: "code",
407
+ sh: "code",
408
+ bat: "code",
409
+ // Spreadsheet files
410
+ xls: "document",
411
+ xlsx: "document",
412
+ csv: "document",
413
+ // Presentation files
414
+ ppt: "document",
415
+ pptx: "document",
416
+ odp: "document",
417
+ };
418
+ const type = types[extension] || "file";
419
+ return `/public/${type}.svg`;
420
+ },
421
+
422
+ // Enhanced method to get attachment display info for UI
423
+ getAttachmentDisplayInfo(attachment) {
424
+ if (typeof attachment === "string") {
425
+ // attachment is filename only (from persistent storage)
426
+ const filename = attachment;
427
+ const extension = filename.split(".").pop();
428
+ const isImage = this.isImageFile(filename);
429
+ const previewUrl = isImage
430
+ ? this.getServerImgUrl(filename)
431
+ : this.getFilePreviewUrl(filename);
432
+
433
+ return {
434
+ filename: filename,
435
+ extension: extension.toUpperCase(),
436
+ isImage: isImage,
437
+ previewUrl: previewUrl,
438
+ clickHandler: () => {
439
+ if (this.isImageFile(filename)) {
440
+ imageViewerStore.open(this.getServerImgUrl(filename), { name: filename });
441
+ } else {
442
+ this.downloadAttachment(filename);
443
+ }
444
+ },
445
+ };
446
+ } else {
447
+ // attachment is object (from current session)
448
+ const isImage = this.isImageFile(attachment.name);
449
+ const filename = attachment.name;
450
+ const extension = filename.split(".").pop() || "";
451
+ const previewUrl = isImage
452
+ ? this.getServerImgUrl(attachment.name)
453
+ : this.getFilePreviewUrl(attachment.name);
454
+ return {
455
+ filename: filename,
456
+ extension: extension.toUpperCase(),
457
+ isImage: attachment.type === "image",
458
+ previewUrl: previewUrl,
459
+ clickHandler: () => {
460
+ if (attachment.type === "image") {
461
+ const imageUrl = this.getServerImgUrl(attachment.name);
462
+ imageViewerStore.open(imageUrl, { name: attachment.name });
463
+ } else {
464
+ this.downloadAttachment(attachment.name);
465
+ }
466
+ },
467
+ };
468
+ }
469
+ },
470
+
471
+ async downloadAttachment(filename) {
472
+ try {
473
+ const path = this.getServerFileUrl(filename);
474
+ const response = await fetchApi("/download_work_dir_file?path=" + path);
475
+
476
+ if (!response.ok) {
477
+ throw new Error("Network response was not ok");
478
+ }
479
+
480
+ const blob = await response.blob();
481
+
482
+ const link = document.createElement("a");
483
+ link.href = window.URL.createObjectURL(blob);
484
+ link.download = filename;
485
+ document.body.appendChild(link);
486
+ link.click();
487
+ document.body.removeChild(link);
488
+ window.URL.revokeObjectURL(link.href);
489
+ } catch (error) {
490
+ window.toastFetchError("Error downloading file", error);
491
+ alert("Error downloading file");
492
+ }
493
+ },
494
+
495
+ // Generate GUID for unique filenames
496
+ generateGUID() {
497
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
498
+ /[xy]/g,
499
+ function (c) {
500
+ const r = (Math.random() * 16) | 0;
501
+ const v = c == "x" ? r : (r & 0x3) | 0x8;
502
+ return v.toString(16);
503
+ }
504
+ );
505
+ },
506
+
507
+ };
508
+
509
+ const store = createStore("chatAttachments", model);
510
+
511
+ export { store };
webui/components/chat/attachments/dragDropOverlay.html CHANGED
@@ -1,3 +1,28 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7171cdc28bf4c422adaef686aa8502f87871582b2eb4ab4e8f0a8495f29e6fb0
3
- size 1034
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+
3
+ <head>
4
+ <title>Drag Drop Overlay</title>
5
+
6
+ <script type="module">
7
+ import { store } from "/components/chat/attachments/attachmentsStore.js";
8
+ </script>
9
+ </head>
10
+
11
+ <body>
12
+ <!-- Drag and Drop Overlay -->
13
+ <div x-data>
14
+ <template x-if="$store.chatAttachments">
15
+ <div x-cloak x-show="$store.chatAttachments.dragDropOverlayVisible" id="dragdrop-overlay"
16
+ x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
17
+ x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300"
18
+ x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay">
19
+ <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
20
+ <div class="dragdrop-text">Drop files or folders to attach them to your message</div>
21
+ <div class="dragdrop-subtext"></div>
22
+ </div>
23
+ </template>
24
+ </div>
25
+
26
+ </body>
27
+
28
+ </html>
webui/components/chat/attachments/inputPreview.html CHANGED
@@ -1,3 +1,30 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0255aaa44ae7179e3f8460a7e265f1671ee8f6d5ae362a658a402f90e8a0d00d
3
- size 1561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="module">
2
+ import { store } from "/components/chat/attachments/attachmentsStore.js";
3
+ import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
4
+ </script>
5
+
6
+ <div x-data>
7
+ <template x-if="$store.chatAttachments">
8
+
9
+ <div x-show="$store.chatAttachments.hasAttachments" class="preview-section scrollbar">
10
+ <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
11
+ <div class="attachment-item"
12
+ :class="{'image-type': attachment.type === 'image', 'file-type': attachment.type === 'file'}">
13
+ <template x-if="attachment.type === 'image'">
14
+ <img :src="attachment.url" class="attachment-preview" :alt="attachment.name" style="cursor: pointer;"
15
+ @click="$store.imageViewer.open(attachment.url, { name: attachment.name })">
16
+ </template>
17
+ <template x-if="attachment.type === 'file'">
18
+ <div>
19
+ <img :src="attachment.displayInfo.previewUrl" class="file-icon" :alt="attachment.extension">
20
+ <span class="file-title" x-text="attachment.name"></span>
21
+ </div>
22
+ </template>
23
+ <button @click="$store.chatAttachments.removeAttachment(index)"
24
+ class="remove-attachment">&times;</button>
25
+ </div>
26
+ </template>
27
+ </div>
28
+
29
+ </template>
30
+ </div>
webui/components/chat/input/bottom-actions-bar.html CHANGED
@@ -1,3 +1,98 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:50f132af7eee05b29dd84d7ab248b34e85c81ae9f79e43dc5a48694b4bc2cc6f
3
- size 4662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/components/chat/input/input-store.js";
5
+ </script>
6
+ </head>
7
+ <body>
8
+ <template x-if="$store.chatInput">
9
+ <div class="text-buttons-row chat-bottom-actions-bar">
10
+ <x-extension id="chat-input-bottom-actions-start"></x-extension>
11
+ <button type="button" class="text-button" @click="$store.chatInput.pauseAgent(!$store.chatInput.paused)">
12
+ <template x-if="!$store.chatInput.paused">
13
+ <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14" aria-hidden="true">
14
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
15
+ </svg>
16
+ </template>
17
+ <template x-if="$store.chatInput.paused">
18
+ <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" width="14" height="14" aria-hidden="true">
19
+ <path d="M8 5v14l11-7z"></path>
20
+ </svg>
21
+ </template>
22
+ <span x-text="$store.chatInput.paused ? 'Resume Agent' : 'Pause Agent'"></span>
23
+ </button>
24
+
25
+ <button type="button" class="text-button" id="nudges_window" @click="$store.chatInput.nudge()">
26
+ <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 58" fill="currentColor" width="14" height="14" aria-hidden="true">
27
+ <path d="m11.97,16.32c-.46,0-.91-.25-1.15-.68-.9-1.63-1.36-3.34-1.36-5.1C9.45,4.73,14.18,0,20,0s10.55,4.73,10.55,10.55c0,.87-.13,1.75-.41,2.76-.19.7-.9,1.13-1.62.93-.7-.19-1.12-.92-.93-1.62.21-.79.31-1.44.31-2.07,0-4.36-3.55-7.91-7.91-7.91s-7.91,3.55-7.91,7.91c0,1.3.35,2.59,1.03,3.82.36.64.13,1.44-.51,1.79-.21.11-.42.17-.64.17Z" stroke-width="0.5" stroke="currentColor"/>
28
+ <path d="M34.5,58h-6.18c-3.17,0-6.15-1.23-8.39-3.47L1.16,35.75c-1.54-1.54-1.54-4.05,0-5.59,2.4-2.4,6.27-2.68,8.99-.64l4.58,3.44V10.55c0-2.91,2.36-5.27,5.27-5.27s5.27,2.36,5.27,5.27v8.62c.78-.45,1.68-.71,2.64-.71,2.3,0,4.26,1.48,4.98,3.53.84-.56,1.85-.89,2.93-.89,2.3,0,4.26,1.48,4.98,3.53.84-.56,1.85-.89,2.93-.89,2.91,0,5.27,2.36,5.27,5.27v14.5c0,8-6.51,14.5-14.5,14.5ZM6.03,30.79c-1.1,0-2.19.42-3.01,1.23-.51.51-.51,1.35,0,1.86l18.77,18.78c1.74,1.74,4.06,2.7,6.53,2.7h6.18c6.54,0,11.86-5.32,11.86-11.86v-14.5c0-1.45-1.18-2.64-2.64-2.64s-2.64,1.18-2.64,2.64v1.32c0,.73-.59,1.32-1.32,1.32s-1.32-.59-1.32-1.32v-3.95c0-1.45-1.18-2.64-2.64-2.64s-2.64,1.18-2.64,2.64v3.95c0,.73-.59,1.32-1.32,1.32s-1.32-.59-1.32-1.32v-6.59c0-1.45-1.18-2.64-2.64-2.64s-2.64,1.18-2.64,2.64v6.59c0,.73-.59,1.32-1.32,1.32s-1.32-.59-1.32-1.32V10.55c0-1.45-1.18-2.64-2.64-2.64s-2.64,1.18-2.64,2.64v25.05c0,.5-.28.95-.73,1.18s-.98.18-1.38-.12l-6.69-5.02c-.75-.56-1.65-.84-2.54-.84Z" stroke-width="0.5" stroke="currentColor"/>
29
+ </svg>
30
+ <p>Nudge</p>
31
+ </button>
32
+ <x-extension id="chat-input-bottom-actions-end"></x-extension>
33
+ </div>
34
+ </template>
35
+
36
+ <style>
37
+ .text-buttons-row {
38
+ width: 100%;
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ gap: var(--spacing-xs);
42
+ padding-top: var(--spacing-xs);
43
+ margin-left: var(--spacing-xs);
44
+ }
45
+ .text-buttons-row > x-extension:not(:empty) { display: contents; }
46
+ .text-buttons-row > x-extension:empty { display: none; }
47
+ .text-buttons-row > x-extension > x-component,
48
+ .text-buttons-row > x-extension > x-component > div[x-data] { display: contents; }
49
+ .text-button {
50
+ background-color: transparent;
51
+ border: none;
52
+ border-radius: 5px;
53
+ color: var(--color-text);
54
+ font-family: "Rubik", Arial, Helvetica, sans-serif;
55
+ font-size: 0.6rem;
56
+ padding: 6px var(--spacing-sm);
57
+ cursor: pointer;
58
+ opacity: 0.8;
59
+ transition: all 0.3s;
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--spacing-xs);
63
+ }
64
+ .text-button:hover:not(:disabled) {
65
+ opacity: 1;
66
+ background-color: var(--color-background-hover);
67
+ border-radius: 4px;
68
+ }
69
+ .text-button:active:not(:disabled) { opacity: 0.5; }
70
+ .text-button:disabled {
71
+ opacity: 0.4;
72
+ cursor: not-allowed;
73
+ }
74
+ .text-button svg {
75
+ width: 14px;
76
+ height: 14px;
77
+ flex-shrink: 0;
78
+ }
79
+ .text-button p { margin-block: 0; }
80
+
81
+ @media (max-width: 768px) {
82
+ .text-buttons-row {
83
+ width: 90%;
84
+ white-space: pre-wrap;
85
+ }
86
+ .text-button svg { width: 18px; height: 18px; }
87
+ }
88
+ @media (max-width: 640px) {
89
+ .text-buttons-row {
90
+ width: 90%;
91
+ white-space: pre-wrap;
92
+ }
93
+ .text-button { max-height: 25px; }
94
+ .text-button p { display: none; }
95
+ }
96
+ </style>
97
+ </body>
98
+ </html>
webui/components/chat/input/bottom-actions.html CHANGED
@@ -1,3 +1,161 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:acb3a816b76c3bf32a318d6f297e7b0f57752b191e3af49b3e66702019044738
3
- size 7639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/components/chat/input/input-store.js";
5
+ import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6
+ import { store as historyStore } from "/components/modals/history/history-store.js";
7
+ import { store as contextStore } from "/components/modals/context/context-store.js";
8
+ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9
+ import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
10
+ </script>
11
+ </head>
12
+ <body>
13
+ <template x-if="$store.chatInput">
14
+ <div class="chat-bottom-actions-menu">
15
+ <input
16
+ type="file"
17
+ x-ref="chatFileInput"
18
+ class="chat-bottom-menu-file-input"
19
+ accept="*"
20
+ multiple
21
+ @change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
22
+ >
23
+ <button
24
+ type="button"
25
+ class="chat-bottom-menu-item"
26
+ @click.stop="$refs.chatFileInput?.click()"
27
+ >
28
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
29
+ <path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
30
+ </svg>
31
+ <span>Attach files</span>
32
+ </button>
33
+
34
+ <input
35
+ type="file"
36
+ x-ref="chatFolderInput"
37
+ class="chat-bottom-menu-file-input"
38
+ webkitdirectory
39
+ directory
40
+ multiple
41
+ @change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
42
+ >
43
+ <button
44
+ type="button"
45
+ class="chat-bottom-menu-item"
46
+ @click.stop="$refs.chatFolderInput?.click()"
47
+ >
48
+ <x-icon aria-hidden="true" name="drive_folder_upload"></x-icon>
49
+ <span>Attach folder</span>
50
+ </button>
51
+
52
+ <button
53
+ type="button"
54
+ class="chat-bottom-menu-item"
55
+ @click="$store.mcpServersStore.openFromComposer($store.chats.selectedContext?.project?.name || ''); $store.chatInput.closeChatMoreMenu()"
56
+ >
57
+ <x-icon aria-hidden="true" name="hub"></x-icon>
58
+ <span>MCP Servers</span>
59
+ </button>
60
+
61
+ <button
62
+ type="button"
63
+ class="chat-bottom-menu-item"
64
+ @click="$confirmClick($event, () => { $store.chats.resetChat(); $store.chatInput.closeChatMoreMenu() })"
65
+ :disabled="!$store.chats.selected"
66
+ >
67
+ <x-icon aria-hidden="true" name="delete_sweep"></x-icon>
68
+ <span>Clear Chat</span>
69
+ </button>
70
+
71
+ <button
72
+ type="button"
73
+ class="chat-bottom-menu-item"
74
+ id="history_inspect"
75
+ @click="$store.history.open(); $store.chatInput.closeChatMoreMenu()"
76
+ :disabled="!$store.chats.selected"
77
+ >
78
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="5 10 85 85" width="18" height="18" aria-hidden="true">
79
+ <path fill="currentColor" d="m59.572,57.949c-.41,0-.826-.105-1.207-.325l-9.574-5.528c-.749-.432-1.21-1.231-1.21-2.095v-14.923c0-1.336,1.083-2.419,2.419-2.419s2.419,1.083,2.419,2.419v13.526l8.364,4.829c1.157.668,1.554,2.148.886,3.305-.448.776-1.261,1.21-2.097,1.21Zm30.427-7.947c0,10.684-4.161,20.728-11.716,28.283-6.593,6.59-15.325,10.69-24.59,11.544-1.223.113-2.448.169-3.669.169-7.492,0-14.878-2.102-21.22-6.068l-15.356,5.733c-.888.331-1.887.114-2.557-.556s-.887-1.669-.556-2.557l5.733-15.351c-4.613-7.377-6.704-16.165-5.899-24.891.854-9.266,4.954-17.998,11.544-24.588,7.555-7.555,17.6-11.716,28.285-11.716s20.73,4.161,28.285,11.716c7.555,7.555,11.716,17.599,11.716,28.283Zm-15.137-24.861c-13.71-13.71-36.018-13.71-49.728,0-11.846,11.846-13.682,30.526-4.365,44.417.434.647.53,1.464.257,2.194l-4.303,11.523,11.528-4.304c.274-.102.561-.153.846-.153.474,0,.944.139,1.348.41,13.888,9.315,32.568,7.479,44.417-4.365,13.707-13.708,13.706-36.014,0-49.723Zm-24.861-4.13c-15.989,0-28.996,13.006-28.996,28.992s13.008,28.992,28.996,28.992c1.336,0,2.419-1.083,2.419-2.419s-1.083-2.419-2.419-2.419c-13.32,0-24.157-10.835-24.157-24.153s10.837-24.153,24.157-24.153,24.153,10.835,24.153,24.153c0,1.336,1.083,2.419,2.419,2.419c0-15.986-13.006-28.992-28.992-28.992Zm25.041,33.531c-1.294.347-2.057,1.673-1.71,2.963.343,1.289,1.669,2.057,2.963,1.71,1.289-.343,2.053-1.669,1.71-2.963-.347-1.289-1.673-2.057-2.963-1.71Zm-2.03,6.328c-1.335,0-2.419,1.084-2.419,2.419s1.084,2.419,2.419,2.419,2.419-1.084,2.419-2.419-1.084-2.419-2.419-2.419Zm-3.598,5.587c-1.289-.347-2.615.416-2.963,1.71-.343,1.289.421,2.615,1.71,2.963,1.294.347,2.62-.421,2.963-1.71.347-1.294-.416-2.62-1.71-2.963Zm-4.919,4.462c-1.157-.667-2.638-.27-3.306.887-.667,1.157-.27,2.638.887,3.305,1.157.668,2.638.27,3.306-.887.667-1.157.27-2.638-.887-3.306Zm-9.327,3.04c-.946.946-.946,2.478,0,3.42.942.946,2.473.946,3.42,0,.946-.942.946-2.473,0-3.42-.946-.942-2.478-.946-3.42,0Z"></path>
80
+ </svg>
81
+ <span>History</span>
82
+ </button>
83
+
84
+ <button
85
+ type="button"
86
+ class="chat-bottom-menu-item"
87
+ id="ctx_window"
88
+ @click="$store.context.open(); $store.chatInput.closeChatMoreMenu()"
89
+ :disabled="!$store.chats.selected"
90
+ >
91
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="17 15 70 70" width="18" height="18" fill="currentColor" aria-hidden="true">
92
+ <path d="m63 25c1.1016 0 2-0.89844 2-2s-0.89844-2-2-2h-26c-1.1016 0-2 0.89844-2 2s0.89844 2 2 2z"></path>
93
+ <path d="m63 79c1.1016 0 2-0.89844 2-2s-0.89844-2-2-2h-26c-1.1016 0-2 0.89844-2 2s0.89844 2 2 2z"></path>
94
+ <path d="m68 39h-36c-6.0703 0-11 4.9297-11 11s4.9297 11 11 11h36c6.0703 0 11-4.9297 11-11s-4.9297-11-11-11zm0 18h-36c-3.8594 0-7-3.1406-7-7s3.1406-7 7-7h36c3.8594 0 7 3.1406 7 7s-3.1406 7-7 7z"></path>
95
+ </svg>
96
+ <span>Context</span>
97
+ </button>
98
+ </div>
99
+ </template>
100
+
101
+ <style>
102
+ .chat-bottom-actions-menu {
103
+ display: flex;
104
+ flex-direction: column;
105
+ width: 100%;
106
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
107
+ font-size: 0.8125rem;
108
+ }
109
+
110
+ .chat-bottom-menu-item {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 0.4rem;
114
+ width: 100%;
115
+ margin: 0;
116
+ padding: var(--spacing-xs) var(--spacing-sm);
117
+ border-radius: 0.25rem;
118
+ background: transparent;
119
+ color: var(--color-text);
120
+ font-family: 'Rubik', Arial, Helvetica, sans-serif;
121
+ text-align: left;
122
+ cursor: pointer;
123
+ opacity: 0.88;
124
+ transition: background-color 0.12s ease, opacity 0.12s ease;
125
+ white-space: nowrap;
126
+ box-sizing: border-box;
127
+ }
128
+ button.chat-bottom-menu-item {
129
+ border: none;
130
+ }
131
+ .chat-bottom-menu-file-input {
132
+ position: absolute;
133
+ width: 1px;
134
+ height: 1px;
135
+ padding: 0;
136
+ margin: -1px;
137
+ overflow: hidden;
138
+ clip: rect(0, 0, 0, 0);
139
+ white-space: nowrap;
140
+ border: 0;
141
+ opacity: 0;
142
+ }
143
+ button.chat-bottom-menu-item:hover:not(:disabled) {
144
+ opacity: 1;
145
+ background-color: var(--color-background-hover);
146
+ }
147
+ button.chat-bottom-menu-item:active:not(:disabled) { opacity: 0.72; }
148
+ button.chat-bottom-menu-item:disabled {
149
+ opacity: 0.38;
150
+ cursor: not-allowed;
151
+ pointer-events: none;
152
+ }
153
+ .chat-bottom-menu-item .material-symbols-outlined {
154
+ font-size: 18px;
155
+ flex-shrink: 0;
156
+ opacity: 0.9;
157
+ }
158
+ .chat-bottom-menu-item svg { flex-shrink: 0; opacity: 0.88; }
159
+ </style>
160
+ </body>
161
+ </html>
webui/components/chat/input/chat-bar-input.html CHANGED
@@ -1,3 +1,457 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c828481f4740f859a1157593e3c67a48a28696c7df876ab3e133d102cc369822
3
- size 12869
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+
3
+ <head>
4
+ <script type="module">
5
+ import { store as fullScreenStore } from "/components/modals/full-screen-input/full-screen-store.js";
6
+ import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
7
+ import { store as chatInputStore } from "/components/chat/input/input-store.js";
8
+ </script>
9
+ </head>
10
+
11
+ <body>
12
+ <div x-data>
13
+ <x-extension id="chat-input-box-start"></x-extension>
14
+ <div class="input-row">
15
+ <!-- More actions (+): opens upward; content lives in bottom-actions.html -->
16
+ <div class="attachment-wrapper chat-more-dropdown" @click.outside="$store.chatInput.closeChatMoreMenu()"
17
+ @keydown.escape.window="$store.chatInput.closeChatMoreMenu()">
18
+ <button type="button" class="chat-more-trigger" @click.stop="$store.chatInput.toggleChatMoreMenu()"
19
+ :aria-expanded="$store.chatInput.chatMoreMenuOpen.toString()" aria-label="More actions">
20
+ <x-icon aria-hidden="true" name="add"></x-icon>
21
+ </button>
22
+ <div class="chat-more-panel" x-show="$store.chatInput.chatMoreMenuOpen" x-transition style="display: none;">
23
+ <x-component path="chat/input/bottom-actions.html"></x-component>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Container for composer and expand button -->
28
+ <div id="chat-input-container">
29
+ <div id="chat-input" role="textbox" aria-label="Message" aria-multiline="true" contenteditable="true"
30
+ spellcheck="true" :data-placeholder="$store.chatInput.inputPlaceholder"
31
+ @keydown="$store.chatInput.handleKeydown($event)"
32
+ @input="$store.chatInput.handleInput($event)"
33
+ @paste="$store.chatInput.handlePaste($event)"
34
+ :class="{ 'progress-active': $store.chatInput.progressActive, 'is-empty': !$store.chatInput.message }"
35
+ x-create="$store.chatInput.mountEditor($el)"
36
+ x-destroy="$store.chatInput.unmountEditor($el)"></div>
37
+ <div id="chat-input-progress-placeholder" x-show="$store.chatInput.showProgressPlaceholder"
38
+ x-html="$store.chatInput.progressPlaceholderHtml"
39
+ :class="{ 'progress-active': $store.chatInput.progressActive }" aria-hidden="true" style="display: none;">
40
+ </div>
41
+ <button id="expand-button" @click="$store.fullScreenInputModal.openModal()" aria-label="Expand input">
42
+ <x-icon aria-hidden="true" name="fullscreen"></x-icon>
43
+ </button>
44
+ </div>
45
+
46
+ <div id="chat-buttons-wrapper">
47
+ <!-- Send button -->
48
+ <button class="chat-button" id="send-button" @click="$store.chatInput.activateSendButton()"
49
+ :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle"
50
+ :aria-label="$store.chatInput.sendButtonTitle">
51
+ <x-icon :name="$store.chatInput.sendButtonIcon"></x-icon>
52
+ </button>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Full Screen Input Modal -->
57
+ <x-component path="modals/full-screen-input/full-screen-input.html"></x-component>
58
+ <x-extension id="chat-input-box-end"></x-extension>
59
+ </div>
60
+
61
+
62
+ <style>
63
+ /* Layout rows */
64
+ .input-row {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 0.72rem;
68
+ width: 100%;
69
+ min-height: 4.25rem;
70
+ padding: 0.42rem 0.52rem 0.42rem 0.72rem;
71
+ border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
72
+ border-radius: 8px;
73
+ background: color-mix(in srgb, var(--color-panel) 72%, var(--color-background) 28%);
74
+ box-shadow:
75
+ inset 0 1px 0 rgba(255, 255, 255, 0.035),
76
+ 0 18px 46px rgba(0, 0, 0, 0.18);
77
+ white-space: nowrap;
78
+ transition: border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease;
79
+ }
80
+
81
+ .input-row:focus-within {
82
+ border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%);
83
+ box-shadow:
84
+ inset 0 1px 0 rgba(255, 255, 255, 0.045),
85
+ 0 22px 54px rgba(0, 0, 0, 0.22);
86
+ }
87
+
88
+ .attachment-wrapper {
89
+ position: relative;
90
+ flex-shrink: 0;
91
+ }
92
+
93
+ /* Composer + menu: custom panel (edit here; not shared with file-browser dropdown) */
94
+ .chat-more-dropdown {
95
+ z-index: 1;
96
+ }
97
+
98
+ .chat-more-trigger {
99
+ cursor: pointer;
100
+ color: var(--color-text);
101
+ opacity: 0.82;
102
+ transition: opacity 0.16s ease, background-color 0.16s ease, color 0.16s ease;
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ width: 2.5rem;
107
+ height: 2.5rem;
108
+ padding: 0;
109
+ border: none;
110
+ border-radius: 10px;
111
+ background: transparent;
112
+ }
113
+
114
+ .chat-more-trigger:hover {
115
+ opacity: 1;
116
+ background: color-mix(in srgb, var(--color-border) 36%, transparent);
117
+ }
118
+
119
+ .chat-more-trigger:active {
120
+ opacity: 0.5;
121
+ }
122
+
123
+ .chat-more-trigger .material-symbols-outlined {
124
+ font-size: 1.7rem;
125
+ line-height: 1;
126
+ }
127
+
128
+ .chat-more-panel {
129
+ position: absolute;
130
+ left: 0;
131
+ bottom: 100%;
132
+ margin-bottom: 0.25rem;
133
+ min-width: 11rem;
134
+ max-width: min(18rem, 92vw);
135
+ max-height: min(70vh, 20rem);
136
+ overflow-x: hidden;
137
+ overflow-y: auto;
138
+ background-color: color-mix(in srgb, var(--color-panel) 90%, black 10%);
139
+ border: 1px solid var(--color-border);
140
+ border-radius: 12px;
141
+ box-shadow: 0 16px 38px rgba(0, 0, 0, 0.3);
142
+ z-index: 1050;
143
+ padding: 0.3rem;
144
+ }
145
+
146
+ .light-mode .chat-more-panel {
147
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
148
+ }
149
+
150
+ /* Text input */
151
+ #chat-input-container {
152
+ position: relative;
153
+ width: 100%;
154
+ min-width: 0;
155
+ display: flex;
156
+ align-items: center;
157
+ align-self: stretch;
158
+ }
159
+
160
+ #chat-input {
161
+ background-color: transparent;
162
+ border: 0;
163
+ border-radius: 0;
164
+ color: var(--color-text);
165
+ flex-grow: 1;
166
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
167
+ font-optical-sizing: auto;
168
+ -webkit-font-optical-sizing: auto;
169
+ font-size: 1rem;
170
+ line-height: 1.45;
171
+ max-height: 30vh;
172
+ min-height: 2.8rem;
173
+ width: 100%;
174
+ padding: 0.68rem 2.2rem 0.68rem 0.1rem;
175
+ margin: 0;
176
+ overflow-y: auto;
177
+ white-space: pre-wrap;
178
+ word-break: break-word;
179
+ scroll-behavior: smooth;
180
+ resize: none;
181
+ align-content: center;
182
+ background-clip: border-box;
183
+ outline: none;
184
+ transition: color 0.16s ease, background-color 0.16s ease;
185
+ -webkit-transition: color 0.16s ease, background-color 0.16s ease;
186
+ }
187
+
188
+ #chat-input.is-empty::before {
189
+ content: attr(data-placeholder);
190
+ color: color-mix(in srgb, var(--color-text) 62%, transparent);
191
+ opacity: 0.78;
192
+ pointer-events: none;
193
+ }
194
+
195
+ #chat-input .composer-code-block {
196
+ display: block;
197
+ margin: 0.48rem 0;
198
+ padding: 0.56rem 0.7rem;
199
+ border-radius: 6px;
200
+ background: color-mix(in srgb, var(--color-border) 28%, transparent);
201
+ }
202
+
203
+ #chat-input .composer-code-content {
204
+ min-height: 1.35rem;
205
+ margin: 0;
206
+ outline: none;
207
+ white-space: pre-wrap;
208
+ word-break: break-word;
209
+ font-family: var(--font-family-code, "Roboto Mono", monospace);
210
+ font-size: 0.94rem;
211
+ line-height: 1.45;
212
+ }
213
+
214
+ #chat-input::-webkit-scrollbar {
215
+ width: 6px;
216
+ height: 6px;
217
+ }
218
+
219
+ #chat-input::-webkit-scrollbar-track {
220
+ background: transparent;
221
+ margin: 4px 0;
222
+ border-radius: 6px;
223
+ }
224
+
225
+ #chat-input::-webkit-scrollbar-thumb {
226
+ background-color: rgba(155, 155, 155, 0.5);
227
+ border-radius: 6px;
228
+ -webkit-transition: background-color 0.2s ease;
229
+ transition: background-color 0.2s ease;
230
+ }
231
+
232
+ #chat-input::-webkit-scrollbar-thumb:hover {
233
+ background-color: rgba(155, 155, 155, 0.7);
234
+ }
235
+
236
+ #chat-input:focus {
237
+ outline: none;
238
+ background-color: transparent;
239
+ }
240
+
241
+ /* Progress ghost text animation — gentle pulse on placeholder */
242
+ #chat-input.progress-active.is-empty::before {
243
+ animation: placeholder-pulse 2s ease-in-out infinite;
244
+ }
245
+
246
+ @keyframes placeholder-pulse {
247
+
248
+ 0%,
249
+ 100% {
250
+ opacity: 0.45;
251
+ }
252
+
253
+ 50% {
254
+ opacity: 0.85;
255
+ }
256
+ }
257
+
258
+ #chat-input.progress-active {
259
+ border-color: color-mix(in srgb, var(--color-highlight) 20%, transparent);
260
+ outline-color: color-mix(in srgb, var(--color-highlight) 15%, transparent);
261
+ }
262
+
263
+ #chat-input-progress-placeholder {
264
+ position: absolute;
265
+ top: 0;
266
+ bottom: 0;
267
+ left: 0.12rem;
268
+ right: 2rem;
269
+ color: var(--color-text-muted);
270
+ opacity: 0.7;
271
+ font-family: var(--font-family-code, "Roboto Mono", monospace);
272
+ font-size: 0.9rem;
273
+ line-height: 1.25;
274
+ white-space: nowrap;
275
+ overflow: hidden;
276
+ text-overflow: ellipsis;
277
+ pointer-events: none;
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.35rem;
281
+ }
282
+
283
+ #chat-input-progress-placeholder.progress-active {
284
+ animation: placeholder-pulse 2s ease-in-out infinite;
285
+ }
286
+
287
+ #chat-input-progress-placeholder .chat-input-progress-icon {
288
+ display: inline-flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ font-size: 1.15rem;
292
+ line-height: 1;
293
+ color: var(--color-highlight);
294
+ }
295
+
296
+ #expand-button {
297
+ position: absolute;
298
+ top: 50%;
299
+ right: 0;
300
+ width: 2rem;
301
+ height: 2rem;
302
+ background: transparent;
303
+ border: none;
304
+ cursor: pointer;
305
+ transform: translateY(-50%) scale(0.8);
306
+ transform-origin: center;
307
+ font-size: 1.2rem;
308
+ color: var(--color-text);
309
+ opacity: 0.2;
310
+ transition: opacity 0.2s;
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ padding: 0;
315
+ }
316
+
317
+ #expand-button:hover {
318
+ opacity: 0.7;
319
+ }
320
+
321
+ #expand-button:active {
322
+ opacity: 1;
323
+ }
324
+
325
+ #expand-button .material-symbols-outlined {
326
+ display: block;
327
+ font-size: 1.3rem;
328
+ line-height: 1;
329
+ }
330
+
331
+ /* Chat buttons (Send/Mic) */
332
+ #chat-buttons-wrapper {
333
+ gap: 0.58rem;
334
+ padding-left: 0;
335
+ line-height: 1;
336
+ display: -webkit-flex;
337
+ display: flex;
338
+ align-items: center;
339
+ }
340
+
341
+ .chat-button {
342
+ border: 1px solid transparent;
343
+ border-radius: 50%;
344
+ color: #fff;
345
+ cursor: pointer;
346
+ font-size: var(--font-size-normal);
347
+ height: 3.15rem;
348
+ width: 3.15rem;
349
+ margin: 0;
350
+ display: -webkit-flex;
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: center;
354
+ flex-shrink: 0;
355
+ flex-grow: 0;
356
+ min-width: 3.15rem;
357
+ -webkit-transition: background-color var(--transition-speed), border-color 0.16s ease, box-shadow 0.16s ease, filter 0.12s ease-in-out, transform 0.12s ease;
358
+ transition: background-color var(--transition-speed), border-color 0.16s ease, box-shadow 0.16s ease, filter 0.12s ease-in-out, transform 0.12s ease;
359
+ }
360
+
361
+ #send-button {
362
+ order: 2;
363
+ border-radius: 12px;
364
+ background-color: #4248f1;
365
+ box-shadow: none;
366
+ }
367
+
368
+ #send-button.send-queue {
369
+ background-color: #e67e22;
370
+ box-shadow: none;
371
+ }
372
+
373
+ #send-button.stop {
374
+ background-color: var(--color-error-text);
375
+ box-shadow: none;
376
+ }
377
+
378
+ #send-button:hover {
379
+ background-color: #353bc5;
380
+ filter: none;
381
+ box-shadow: none;
382
+ }
383
+
384
+ #send-button.send-queue:hover {
385
+ background-color: #d35400;
386
+ filter: none;
387
+ }
388
+
389
+ #send-button.stop:hover {
390
+ background-color: color-mix(in srgb, var(--color-error-text) 82%, black);
391
+ filter: none;
392
+ }
393
+
394
+ #send-button:active {
395
+ background-color: #2b309c;
396
+ transform: translateY(1px) scale(0.98);
397
+ filter: brightness(0.96);
398
+ }
399
+
400
+ #send-button.stop:active {
401
+ background-color: color-mix(in srgb, var(--color-error-text) 68%, black);
402
+ }
403
+
404
+ .chat-button svg {
405
+ width: 1.5rem;
406
+ height: 1.5rem;
407
+ }
408
+
409
+ #send-button .material-symbols-outlined {
410
+ font-size: 1.78rem;
411
+ }
412
+
413
+ /* Responsive tweaks */
414
+ @media (max-width: 640px) {
415
+ #chat-input {
416
+ align-content: center;
417
+ }
418
+
419
+ #chat-buttons-wrapper {
420
+ display: flex;
421
+ gap: 0.42rem;
422
+ padding: 0 !important;
423
+ width: auto;
424
+ flex-wrap: nowrap;
425
+ -webkit-transition: all 0.3s ease;
426
+ transition: all 0.3s ease;
427
+ }
428
+
429
+ .input-row {
430
+ gap: 0.42rem;
431
+ min-height: 4rem;
432
+ padding: 0.36rem 0.42rem 0.36rem 0.48rem;
433
+ }
434
+
435
+ .chat-more-trigger {
436
+ width: 2.15rem;
437
+ height: 2.15rem;
438
+ }
439
+
440
+ .chat-button {
441
+ width: 2.75rem;
442
+ height: 2.75rem;
443
+ min-width: 2.75rem;
444
+ }
445
+ }
446
+
447
+ @media (min-width: 768px) {
448
+ #chat-buttons-wrapper {
449
+ flex-wrap: nowrap;
450
+ -webkit-flex-wrap: nowrap;
451
+ flex-wrap: nowrap;
452
+ }
453
+ }
454
+ </style>
455
+ </body>
456
+
457
+ </html>
webui/components/chat/input/chat-bar.html CHANGED
@@ -1,3 +1,52 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7b9ccb53f0c73e706ec6c81d539e42adb8803c904bd7926055afbd0298d6d34a
3
- size 1609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/components/chat/input/input-store.js";
5
+ import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <div id="input-section" x-data>
10
+ <x-extension id="chat-input-start"></x-extension>
11
+ <template x-if="$store.chatInput">
12
+ <div style="width: 100%; display: contents;">
13
+ <!-- Message Queue section -->
14
+ <x-component path="chat/message-queue/message-queue.html"></x-component>
15
+
16
+ <!-- Attachment Preview section -->
17
+ <div>
18
+ <x-component path="chat/attachments/inputPreview.html" />
19
+ </div>
20
+
21
+ <x-component path="chat/input/chat-bar-input.html"></x-component>
22
+ <x-component path="chat/input/bottom-actions-bar.html"></x-component>
23
+ </div>
24
+ </template>
25
+ <x-extension id="chat-input-end"></x-extension>
26
+ </div>
27
+
28
+
29
+ <style>
30
+ /* Input section wrapper */
31
+ #input-section {
32
+ position: relative;
33
+ background-color: var(--color-panel);
34
+ display: -webkit-flex;
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: var(--spacing-xs);
38
+ padding: var(--spacing-xxs) var(--spacing-md) var(--spacing-sm) 0.8rem;
39
+ align-items: start;
40
+ place-items: normal;
41
+ flex-shrink: 0;
42
+ overflow: visible;
43
+ z-index: 1001;
44
+ }
45
+
46
+ @media (max-width: 768px) {
47
+ #input-section { align-items: normal !important; }
48
+ }
49
+
50
+ </style>
51
+ </body>
52
+ </html>
webui/components/chat/input/composer-banner-store.js CHANGED
@@ -1,3 +1,69 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f75dbff9419edd621acc868de8a904fd8e910e89555a68a9eadedb8e9562a8c2
3
- size 2088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { callJsonApi } from "/js/api.js";
3
+ import { getCurrentUserISOString } from "/js/time-utils.js";
4
+
5
+ function buildBannersContext() {
6
+ return {
7
+ url: window.location.href,
8
+ protocol: window.location.protocol,
9
+ hostname: window.location.hostname,
10
+ port: window.location.port,
11
+ browser: navigator.userAgent,
12
+ timestamp: getCurrentUserISOString(),
13
+ };
14
+ }
15
+
16
+ export const store = createStore("composerBanner", {
17
+ missingApiKeys: [],
18
+ hasMissingApiKeyBanner: false,
19
+ loading: false,
20
+ lastRefresh: 0,
21
+ _modalCloseBound: false,
22
+
23
+ get hasMissingApiKeys() {
24
+ return this.hasMissingApiKeyBanner;
25
+ },
26
+
27
+ get missingApiKeysSummaryText() {
28
+ if (!this.hasMissingApiKeys) return "";
29
+ if (!Array.isArray(this.missingApiKeys) || this.missingApiKeys.length === 0) {
30
+ return "Configure your model provider API keys to continue.";
31
+ }
32
+ return this.missingApiKeys
33
+ .map((p) => `${p.model_type} (${p.provider})`)
34
+ .join(", ");
35
+ },
36
+
37
+ init() {
38
+ if (this._modalCloseBound) return;
39
+ this._modalCloseBound = true;
40
+ document.addEventListener("modal-closed", () => {
41
+ this.refresh(true);
42
+ });
43
+ },
44
+
45
+ async refresh(force = false) {
46
+ const now = Date.now();
47
+ if (!force && now - this.lastRefresh < 1000) return;
48
+ this.lastRefresh = now;
49
+ this.loading = true;
50
+ try {
51
+ const response = await callJsonApi("/banners", {
52
+ banners: [],
53
+ context: buildBannersContext(),
54
+ });
55
+ const banners = Array.isArray(response?.banners) ? response.banners : [];
56
+ const missingApiKeyBanner = banners.find((banner) => banner?.id === "missing-api-key");
57
+ this.hasMissingApiKeyBanner = !!missingApiKeyBanner;
58
+ this.missingApiKeys = Array.isArray(missingApiKeyBanner?.missing_providers)
59
+ ? missingApiKeyBanner.missing_providers
60
+ : [];
61
+ } catch (e) {
62
+ console.error("composerBanner refresh failed", e);
63
+ this.hasMissingApiKeyBanner = false;
64
+ this.missingApiKeys = [];
65
+ } finally {
66
+ this.loading = false;
67
+ }
68
+ },
69
+ });
webui/components/chat/input/input-store.js CHANGED
@@ -1,3 +1,780 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9f9248f9813e804fce49f05b0f922faf8638220648c5f1b399742a5a90babc56
3
- size 23500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import * as shortcuts from "/js/shortcuts.js";
3
+ import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4
+ import { openLatest as openLatestSurface } from "/js/surfaces.js";
5
+ import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6
+ import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
7
+ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8
+
9
+ const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
10
+ const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
11
+ const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
12
+ const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
13
+
14
+ function escapeHTML(value) {
15
+ return String(value ?? "")
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;")
20
+ .replace(/'/g, "&#039;");
21
+ }
22
+
23
+ function unescapeIconTooltip(block) {
24
+ if (!block) return "";
25
+ return block
26
+ .slice(1, -1)
27
+ .replace(/\\\[/g, "[")
28
+ .replace(/\\\]/g, "]")
29
+ .replace(/\\\\/g, "\\");
30
+ }
31
+
32
+ function convertIconMarkersToHtml(value) {
33
+ const text = String(value ?? "");
34
+ let html = "";
35
+ let lastIndex = 0;
36
+
37
+ text.replace(ICON_MARKER_RE, (match, iconName, tooltipBlock, offset) => {
38
+ html += escapeHTML(text.slice(lastIndex, offset));
39
+ const tooltip = unescapeIconTooltip(tooltipBlock) || iconName;
40
+ html += (
41
+ `<x-icon class="icon chat-input-progress-icon" ` +
42
+ `title="${escapeHTML(tooltip)}" name="${escapeHTML(iconName)}"></x-icon>`
43
+ );
44
+ lastIndex = offset + match.length;
45
+ return match;
46
+ });
47
+
48
+ html += escapeHTML(text.slice(lastIndex));
49
+ return html;
50
+ }
51
+
52
+ const model = {
53
+ paused: false,
54
+ _message: "",
55
+ _editorEl: null,
56
+ _history: [],
57
+ _historyIndex: null,
58
+ _draft: "",
59
+ _historyCtxid: null,
60
+ _draftCtxid: null,
61
+ /** Composer + menu (bottom actions moved into dropdown) */
62
+ chatMoreMenuOpen: false,
63
+ progressText: "",
64
+ progressActive: false,
65
+
66
+ get message() {
67
+ return this._message;
68
+ },
69
+
70
+ set message(value) {
71
+ this._message = String(value ?? "");
72
+ this._renderEditorFromText(this._message);
73
+ this._saveDraft();
74
+ },
75
+
76
+ toggleChatMoreMenu() {
77
+ this.chatMoreMenuOpen = !this.chatMoreMenuOpen;
78
+ },
79
+
80
+ closeChatMoreMenu() {
81
+ this.chatMoreMenuOpen = false;
82
+ },
83
+
84
+ _getSendState() {
85
+ const hasInput = this.message.trim() || attachmentsStore?.attachments?.length > 0;
86
+ const hasQueue = !!messageQueueStore?.hasQueue;
87
+ const running = !!chatsStore.selectedContext?.running;
88
+
89
+ if (running && !hasInput) return "stop";
90
+ if (hasQueue && !hasInput) return "all";
91
+ if ((running || hasQueue) && hasInput) return "queue";
92
+ return "normal";
93
+ },
94
+
95
+ get inputPlaceholder() {
96
+ if (!chatsStore.selected) return "Ask anything to start a new chat";
97
+ const state = this._getSendState();
98
+ if ((state === "all" || state === "stop") && messageQueueStore?.hasQueue) {
99
+ return "Press Enter to send queued messages";
100
+ }
101
+ if (this.showProgressPlaceholder) return "";
102
+ return "Type your message here...";
103
+ },
104
+
105
+ get showProgressPlaceholder() {
106
+ const state = this._getSendState();
107
+ return (
108
+ !!chatsStore.selected &&
109
+ state !== "all" &&
110
+ !(state === "stop" && messageQueueStore?.hasQueue) &&
111
+ !!this.progressText &&
112
+ !this.message
113
+ );
114
+ },
115
+
116
+ get progressPlaceholderHtml() {
117
+ return convertIconMarkersToHtml(this.progressText);
118
+ },
119
+
120
+ // Computed: send button icon type
121
+ get sendButtonIcon() {
122
+ const state = this._getSendState();
123
+ if (state === "stop") return "stop";
124
+ if (state === "all") return "send_and_archive";
125
+ if (state === "queue") return "schedule_send";
126
+ return "arrow_forward";
127
+ },
128
+
129
+ // Computed: send button CSS class
130
+ get sendButtonClass() {
131
+ const state = this._getSendState();
132
+ if (state === "stop") return "stop";
133
+ if (state === "all") return "send-queue send-all";
134
+ if (state === "queue") return "send-queue queue";
135
+ return "";
136
+ },
137
+
138
+ // Computed: send button title
139
+ get sendButtonTitle() {
140
+ const state = this._getSendState();
141
+ if (state === "stop") return "Stop agent";
142
+ if (state === "all") return "Send all queued messages";
143
+ if (state === "queue") return "Add to queue";
144
+ return "Send message";
145
+ },
146
+
147
+ init() {
148
+ console.log("Input store initialized");
149
+ // Event listeners are now handled via Alpine directives in the component
150
+ },
151
+
152
+ async sendMessage() {
153
+ this._syncMessageFromEditor();
154
+ const pendingMessage = this.message;
155
+
156
+ if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
157
+ const ctxid = await chatsStore.newChat();
158
+ if (!ctxid && !chatsStore.selected) return;
159
+ this.message = pendingMessage;
160
+ }
161
+
162
+ // Capture sent prompt to per-chat history (bash-style)
163
+ try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
164
+
165
+ // Delegate to the global function
166
+ if (globalThis.sendMessage) {
167
+ await globalThis.sendMessage();
168
+ }
169
+ },
170
+
171
+ async activateSendButton() {
172
+ this._syncMessageFromEditor();
173
+ if (this._getSendState() === "stop") {
174
+ await this.stopAgent();
175
+ return;
176
+ }
177
+ await this.sendMessage();
178
+ },
179
+
180
+ mountEditor(editor) {
181
+ this._editorEl = editor;
182
+ this.setDraftContext(shortcuts.getCurrentContextId());
183
+ this._renderEditorFromText(this._message);
184
+ this.adjustTextareaHeight({ target: editor });
185
+ },
186
+
187
+ unmountEditor(editor) {
188
+ if (this._editorEl === editor) this._editorEl = null;
189
+ },
190
+
191
+ _composerTextareas(target = null) {
192
+ const editor = target?.closest?.("#chat-input") || this._editorEl || document.getElementById("chat-input");
193
+ return editor ? [editor] : [];
194
+ },
195
+
196
+ _activeTextarea() {
197
+ const active = document.activeElement;
198
+ const activeEditor = active?.closest?.("#chat-input");
199
+ if (activeEditor) {
200
+ return activeEditor;
201
+ }
202
+ return this._editorEl || document.getElementById("chat-input");
203
+ },
204
+
205
+ _renderEditorFromText(text) {
206
+ const editor = this._editorEl;
207
+ if (!editor) return;
208
+ if (editor.textContent !== text || editor.querySelector("[data-code-block]")) {
209
+ editor.textContent = text;
210
+ }
211
+ this._setEditorEmptyState();
212
+ },
213
+
214
+ _setEditorEmptyState() {
215
+ const editor = this._editorEl;
216
+ if (editor) editor.classList.toggle("is-empty", !this._message);
217
+ },
218
+
219
+ _createCodeBlock(lang = "") {
220
+ const block = document.createElement("div");
221
+ block.className = "composer-code-block";
222
+ block.dataset.codeBlock = "true";
223
+ block.dataset.lang = lang;
224
+ block.contentEditable = "false";
225
+
226
+ const code = document.createElement("pre");
227
+ code.className = "composer-code-content";
228
+ code.dataset.codeContent = "true";
229
+ code.contentEditable = "true";
230
+ code.spellcheck = false;
231
+ code.setAttribute("aria-label", "Code block");
232
+ block.append(code);
233
+
234
+ return block;
235
+ },
236
+
237
+ _plainText(node) {
238
+ if (!node) return "";
239
+ if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || "";
240
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
241
+ if (node.matches?.("[data-code-block]")) {
242
+ const lang = node.dataset.lang || "";
243
+ const code = this._plainText(node.querySelector("[data-code-content]")).replace(/\n$/, "");
244
+ return "```" + lang + "\n" + code + "\n```";
245
+ }
246
+ if (node.tagName === "BR") return "\n";
247
+
248
+ let text = "";
249
+ for (const child of node.childNodes) text += this._plainText(child);
250
+ if (node !== this._editorEl && BLOCK_TAGS.has(node.tagName) && text && !text.endsWith("\n")) {
251
+ text += "\n";
252
+ }
253
+ return text;
254
+ },
255
+
256
+ _editorToMarkdown() {
257
+ return this._plainText(this._editorEl).replace(/\n$/, "");
258
+ },
259
+
260
+ _syncMessageFromEditor() {
261
+ if (!this._editorEl) return;
262
+ this._message = this._editorToMarkdown();
263
+ this._setEditorEmptyState();
264
+ this._saveDraft();
265
+ },
266
+
267
+ _isInCodeBlock(target) {
268
+ return Boolean(target?.closest?.("[data-code-content]"));
269
+ },
270
+
271
+ _selectionOffsets(editor) {
272
+ const selection = document.getSelection?.();
273
+ if (!selection || selection.rangeCount === 0 || !editor) {
274
+ return { start: this._message.length, end: this._message.length };
275
+ }
276
+ const range = selection.getRangeAt(0);
277
+ if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) {
278
+ return { start: this._message.length, end: this._message.length };
279
+ }
280
+
281
+ const before = range.cloneRange();
282
+ before.selectNodeContents(editor);
283
+ before.setEnd(range.startContainer, range.startOffset);
284
+
285
+ const selected = range.cloneRange();
286
+ return {
287
+ start: before.toString().length,
288
+ end: before.toString().length + selected.toString().length,
289
+ };
290
+ },
291
+
292
+ _setEditorCaret(offset) {
293
+ const editor = this._activeTextarea();
294
+ const selection = document.getSelection?.();
295
+ if (!editor || !selection) return;
296
+
297
+ editor.focus();
298
+ const range = document.createRange();
299
+ let remaining = Math.max(0, Number(offset) || 0);
300
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
301
+ let node = walker.nextNode();
302
+
303
+ while (node) {
304
+ const length = node.nodeValue.length;
305
+ if (remaining <= length) {
306
+ range.setStart(node, remaining);
307
+ range.collapse(true);
308
+ selection.removeAllRanges();
309
+ selection.addRange(range);
310
+ return;
311
+ }
312
+ remaining -= length;
313
+ node = walker.nextNode();
314
+ }
315
+
316
+ range.selectNodeContents(editor);
317
+ range.collapse(false);
318
+ selection.removeAllRanges();
319
+ selection.addRange(range);
320
+ },
321
+
322
+ _focusCodeBlock(block) {
323
+ queueMicrotask(() => {
324
+ const code = block.querySelector("[data-code-content]");
325
+ const selection = document.getSelection?.();
326
+ if (!code || !selection) return;
327
+ code.focus();
328
+ const range = document.createRange();
329
+ range.selectNodeContents(code);
330
+ range.collapse(false);
331
+ selection.removeAllRanges();
332
+ selection.addRange(range);
333
+ });
334
+ },
335
+
336
+ _insertPlainText(text) {
337
+ const selection = document.getSelection?.();
338
+ if (!selection || selection.rangeCount === 0) return false;
339
+ const range = selection.getRangeAt(0);
340
+ range.deleteContents();
341
+ const node = document.createTextNode(text);
342
+ range.insertNode(node);
343
+ range.setStart(node, node.nodeValue.length);
344
+ range.collapse(true);
345
+ selection.removeAllRanges();
346
+ selection.addRange(range);
347
+ return true;
348
+ },
349
+
350
+ _tryCreateCodeBlock($event) {
351
+ const editor = this._activeTextarea();
352
+ const selection = document.getSelection?.();
353
+ if (!editor || !selection || selection.rangeCount === 0) return false;
354
+
355
+ const range = selection.getRangeAt(0);
356
+ if (!range.collapsed || !editor.contains(range.startContainer)) return false;
357
+ if (this._isInCodeBlock(range.startContainer.parentElement)) return false;
358
+
359
+ const before = range.cloneRange();
360
+ before.selectNodeContents(editor);
361
+ before.setEnd(range.startContainer, range.startOffset);
362
+
363
+ const after = range.cloneRange();
364
+ after.selectNodeContents(editor);
365
+ after.setStart(range.startContainer, range.startOffset);
366
+
367
+ const lineBefore = before.toString().split("\n").pop() || "";
368
+ const lineAfter = (after.toString().split("\n")[0] || "");
369
+ const match = (lineBefore + lineAfter).match(FENCE_LINE_RE);
370
+ if (!match || lineAfter) return false;
371
+ if (range.startContainer.nodeType !== Node.TEXT_NODE || range.startOffset < lineBefore.length) return false;
372
+
373
+ $event.preventDefault();
374
+ const editRange = range.cloneRange();
375
+ editRange.setStart(range.startContainer, range.startOffset - lineBefore.length);
376
+ editRange.deleteContents();
377
+
378
+ const block = this._createCodeBlock(match[1] || "");
379
+ editRange.insertNode(block);
380
+ block.after(document.createTextNode("\n"));
381
+ this._focusCodeBlock(block);
382
+ this._syncMessageFromEditor();
383
+ this.adjustTextareaHeight({ target: editor });
384
+ return true;
385
+ },
386
+
387
+ handleInput($event) {
388
+ this._syncMessageFromEditor();
389
+ this.adjustTextareaHeight($event);
390
+ },
391
+
392
+ handlePaste($event) {
393
+ const text = $event.clipboardData?.getData("text/plain");
394
+ if (text === undefined) return;
395
+ $event.preventDefault();
396
+ this._insertPlainText(text);
397
+ this.handleInput($event);
398
+ },
399
+
400
+ handleKeydown($event) {
401
+ if ($event.isComposing || $event.keyCode === 229) return;
402
+
403
+ if ($event.key === "Enter") {
404
+ if (this._isInCodeBlock($event.target)) return;
405
+ if (!$event.shiftKey) {
406
+ if (this._tryCreateCodeBlock($event)) return;
407
+ $event.preventDefault();
408
+ this.sendMessage();
409
+ }
410
+ return;
411
+ }
412
+
413
+ if ($event.key === "ArrowUp") this.historyPrev($event);
414
+ if ($event.key === "ArrowDown") this.historyNext($event);
415
+ },
416
+
417
+ adjustTextareaHeight($event = null) {
418
+ const target = $event?.target || null;
419
+ for (const chatInput of this._composerTextareas(target)) {
420
+ chatInput.style.height = "auto";
421
+ chatInput.style.height = chatInput.scrollHeight + "px";
422
+ // pick up any layout shift triggered by the height assignment
423
+ chatInput.style.height = Math.max(chatInput.scrollHeight, parseInt(chatInput.style.height)) + "px";
424
+ }
425
+ },
426
+
427
+ async pauseAgent(paused) {
428
+ const prev = this.paused;
429
+ this.paused = paused;
430
+ try {
431
+ const context = globalThis.getContext?.();
432
+ if (!globalThis.sendJsonData)
433
+ throw new Error("sendJsonData not available");
434
+ await globalThis.sendJsonData("/pause", { paused, context });
435
+ } catch (e) {
436
+ this.paused = prev;
437
+ if (globalThis.toastFetchError) {
438
+ globalThis.toastFetchError("Error pausing agent", e);
439
+ }
440
+ }
441
+ },
442
+
443
+ async stopAgent() {
444
+ try {
445
+ const context = globalThis.getContext?.();
446
+ if (!context || !globalThis.sendJsonData) return;
447
+ await globalThis.sendJsonData("/stop", { context });
448
+ } catch (e) {
449
+ if (globalThis.toastFetchError) {
450
+ globalThis.toastFetchError("Error stopping agent", e);
451
+ }
452
+ }
453
+ },
454
+
455
+ async nudge() {
456
+ try {
457
+ const context = globalThis.getContext();
458
+ await globalThis.sendJsonData("/nudge", { ctxid: context });
459
+ } catch (e) {
460
+ if (globalThis.toastFetchError) {
461
+ globalThis.toastFetchError("Error nudging agent", e);
462
+ }
463
+ }
464
+ },
465
+
466
+ async loadKnowledge() {
467
+ try {
468
+ const resp = await shortcuts.callJsonApi(
469
+ "/plugins/_memory/knowledge_path_get",
470
+ { ctxid: shortcuts.getCurrentContextId() }
471
+ );
472
+ if (!resp.ok) throw new Error("Error getting knowledge path");
473
+ const path = resp.path;
474
+
475
+ // open file browser and wait for it to close
476
+ await fileBrowserStore.open(path);
477
+
478
+ // progress notification
479
+ shortcuts.frontendNotification({
480
+ type: shortcuts.NotificationType.PROGRESS,
481
+ message: "Loading knowledge...",
482
+ priority: shortcuts.NotificationPriority.NORMAL,
483
+ displayTime: 999,
484
+ group: "knowledge_load",
485
+ frontendOnly: true,
486
+ });
487
+
488
+ // then reindex knowledge
489
+ await globalThis.sendJsonData("/plugins/_memory/knowledge_reindex", {
490
+ ctxid: shortcuts.getCurrentContextId(),
491
+ });
492
+
493
+ // finished notification
494
+ shortcuts.frontendNotification({
495
+ type: shortcuts.NotificationType.SUCCESS,
496
+ message: "Knowledge loaded successfully",
497
+ priority: shortcuts.NotificationPriority.NORMAL,
498
+ displayTime: 2,
499
+ group: "knowledge_load",
500
+ frontendOnly: true,
501
+ });
502
+ } catch (e) {
503
+ // error notification
504
+ shortcuts.frontendNotification({
505
+ type: shortcuts.NotificationType.ERROR,
506
+ message: "Error loading knowledge",
507
+ priority: shortcuts.NotificationPriority.NORMAL,
508
+ displayTime: 5,
509
+ group: "knowledge_load",
510
+ frontendOnly: true,
511
+ });
512
+ }
513
+ },
514
+
515
+ // previous implementation without projects
516
+ async _loadKnowledge() {
517
+ const input = document.createElement("input");
518
+ input.type = "file";
519
+ input.accept = ".txt,.pdf,.csv,.html,.json,.md";
520
+ input.multiple = true;
521
+
522
+ input.onchange = async () => {
523
+ try {
524
+ const formData = new FormData();
525
+ for (let file of input.files) {
526
+ formData.append("files[]", file);
527
+ }
528
+
529
+ formData.append("ctxid", globalThis.getContext());
530
+
531
+ const response = await globalThis.fetchApi("/import_knowledge", {
532
+ method: "POST",
533
+ body: formData,
534
+ });
535
+
536
+ if (!response.ok) {
537
+ if (globalThis.toast)
538
+ globalThis.toast(await response.text(), "error");
539
+ } else {
540
+ const data = await response.json();
541
+ if (globalThis.toast) {
542
+ globalThis.toast(
543
+ "Knowledge files imported: " + data.filenames.join(", "),
544
+ "success"
545
+ );
546
+ }
547
+ }
548
+ } catch (e) {
549
+ if (globalThis.toastFetchError) {
550
+ globalThis.toastFetchError("Error loading knowledge", e);
551
+ }
552
+ }
553
+ };
554
+
555
+ input.click();
556
+ },
557
+
558
+ async browseFiles(path) {
559
+ if (!path) {
560
+ const ctxid = shortcuts.getCurrentContextId();
561
+
562
+ if (ctxid) {
563
+ try {
564
+ const resp = await shortcuts.callJsonApi("/chat_files_path_get", {
565
+ ctxid,
566
+ });
567
+ if (resp.ok) path = resp.path;
568
+ } catch (_e) {
569
+ console.error("Error getting chat files path", _e);
570
+ }
571
+ }
572
+ }
573
+ let opened = false;
574
+ try {
575
+ opened = await openLatestSurface("files", { path, source: "sidebar" });
576
+ } catch (error) {
577
+ console.error("Error opening Files surface", error);
578
+ }
579
+ if (!opened) await fileBrowserStore.open(path);
580
+ },
581
+
582
+ focus() {
583
+ const chatInput = this._activeTextarea();
584
+ if (chatInput) {
585
+ chatInput.focus();
586
+ }
587
+ },
588
+
589
+ setDraftContext(ctxid) {
590
+ const nextCtxid = String(ctxid || "");
591
+ if (nextCtxid === this._draftCtxid) return;
592
+ if (this._draftCtxid !== null) this._syncMessageFromEditor();
593
+
594
+ this._draftCtxid = nextCtxid;
595
+ this._historyIndex = null;
596
+ this._draft = "";
597
+
598
+ let draft = "";
599
+ if (nextCtxid) {
600
+ try { draft = sessionStorage.getItem(DRAFT_STORAGE_PREFIX + nextCtxid) || ""; } catch (_e) { /* ignore */ }
601
+ }
602
+ this._message = draft;
603
+ this._renderEditorFromText(draft);
604
+ queueMicrotask(() => this.adjustTextareaHeight());
605
+ },
606
+
607
+ _saveDraft() {
608
+ if (!this._draftCtxid) return;
609
+ try {
610
+ const key = DRAFT_STORAGE_PREFIX + this._draftCtxid;
611
+ if (this._message) sessionStorage.setItem(key, this._message);
612
+ else sessionStorage.removeItem(key);
613
+ } catch (_e) { /* ignore unavailable storage */ }
614
+ },
615
+
616
+ _loadHistory() {
617
+ let ctxid = null;
618
+ try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
619
+ this._historyCtxid = ctxid;
620
+ this._history = [];
621
+ this._historyIndex = null;
622
+ this._draft = "";
623
+ if (!ctxid) return;
624
+ let raw = null;
625
+ try { raw = localStorage.getItem("a0:chat-history:" + ctxid); } catch (_e) { raw = null; }
626
+ if (raw !== null) {
627
+ try {
628
+ const arr = JSON.parse(raw);
629
+ if (Array.isArray(arr)) {
630
+ this._history = arr.filter((s) => typeof s === "string");
631
+ }
632
+ } catch (_e) { /* ignore */ }
633
+ return;
634
+ }
635
+ // No entry yet for this chat: seed from rendered chat DOM (one-time bootstrap)
636
+ try {
637
+ const seeded = this._seedFromChatDom();
638
+ if (seeded.length > 0) {
639
+ this._history = seeded;
640
+ this._saveHistory();
641
+ } else {
642
+ // Persist an empty array so we don't re-seed on every nav; respects user clearing
643
+ this._saveHistory();
644
+ }
645
+ } catch (_e) { /* ignore */ }
646
+ },
647
+
648
+ _seedFromChatDom() {
649
+ const out = [];
650
+ let nodes;
651
+ try {
652
+ nodes = document.querySelectorAll(".user-container .message-user .message-text pre");
653
+ } catch (_e) {
654
+ return out;
655
+ }
656
+ for (const pre of nodes) {
657
+ const text = (pre.textContent || "").trim();
658
+ if (!text) continue;
659
+ if (out.length > 0 && out[out.length - 1] === text) continue; // ignoredups
660
+ out.push(text);
661
+ }
662
+ if (out.length > 50) return out.slice(-50);
663
+ return out;
664
+ },
665
+
666
+ _saveHistory() {
667
+ if (!this._historyCtxid) return;
668
+ try {
669
+ localStorage.setItem(
670
+ "a0:chat-history:" + this._historyCtxid,
671
+ JSON.stringify(this._history)
672
+ );
673
+ } catch (_e) { /* ignore quota / disabled */ }
674
+ },
675
+
676
+ _ensureHistoryLoaded() {
677
+ let ctxid = null;
678
+ try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
679
+ if (ctxid !== this._historyCtxid) {
680
+ this._loadHistory();
681
+ }
682
+ },
683
+
684
+ _pushHistory(text) {
685
+ if (typeof text !== "string") return;
686
+ const trimmed = text.trim();
687
+ if (!trimmed) return;
688
+ this._ensureHistoryLoaded();
689
+ if (this._history.length > 0 && this._history[this._history.length - 1] === trimmed) {
690
+ this._historyIndex = null;
691
+ this._draft = "";
692
+ return;
693
+ }
694
+ this._history.push(trimmed);
695
+ if (this._history.length > 50) {
696
+ this._history = this._history.slice(-50);
697
+ }
698
+ this._saveHistory();
699
+ this._historyIndex = null;
700
+ this._draft = "";
701
+ },
702
+
703
+ _setCaretStart() {
704
+ queueMicrotask(() => {
705
+ const editor = this._activeTextarea();
706
+ if (editor) {
707
+ this._setEditorCaret(0);
708
+ try { editor.scrollTop = 0; } catch (_e) { /* ignore */ }
709
+ }
710
+ this.adjustTextareaHeight();
711
+ });
712
+ },
713
+
714
+ _setCaretEnd() {
715
+ queueMicrotask(() => {
716
+ const editor = this._activeTextarea();
717
+ if (editor) {
718
+ this._setEditorCaret(editor.innerText.length);
719
+ try { editor.scrollTop = editor.scrollHeight; } catch (_e) { /* ignore */ }
720
+ }
721
+ this.adjustTextareaHeight();
722
+ });
723
+ },
724
+
725
+ historyPrev($event) {
726
+ if ($event && ($event.isComposing || $event.keyCode === 229)) return;
727
+ if (this._isInCodeBlock($event?.target)) return;
728
+ const editor = this._activeTextarea();
729
+ if (!editor) return;
730
+ const { start, end } = this._selectionOffsets(editor);
731
+ if (start !== 0 || end !== 0) return;
732
+ $event.preventDefault();
733
+ this._ensureHistoryLoaded();
734
+ if (this._history.length === 0) return;
735
+ if (this._historyIndex === null) {
736
+ this._draft = this.message || "";
737
+ this._historyIndex = this._history.length - 1;
738
+ } else if (this._historyIndex > 0) {
739
+ this._historyIndex -= 1;
740
+ } else {
741
+ return;
742
+ }
743
+ this.message = this._history[this._historyIndex];
744
+ this._setCaretStart();
745
+ },
746
+
747
+ historyNext($event) {
748
+ if ($event && ($event.isComposing || $event.keyCode === 229)) return;
749
+ if (this._isInCodeBlock($event?.target)) return;
750
+ const editor = this._activeTextarea();
751
+ if (!editor) return;
752
+ const { start, end } = this._selectionOffsets(editor);
753
+ const valueLength = editor.innerText.length;
754
+ if (start !== valueLength || end !== valueLength) return;
755
+ $event.preventDefault();
756
+ if (this._historyIndex === null) return;
757
+ if (this._historyIndex < this._history.length - 1) {
758
+ this._historyIndex += 1;
759
+ this.message = this._history[this._historyIndex];
760
+ } else {
761
+ this._historyIndex = null;
762
+ this.message = this._draft || "";
763
+ this._draft = "";
764
+ }
765
+ this._setCaretEnd();
766
+ },
767
+
768
+ reset() {
769
+ this.message = "";
770
+ attachmentsStore.clearAttachments();
771
+ this.chatMoreMenuOpen = false;
772
+ this._historyIndex = null;
773
+ this._draft = "";
774
+ this.adjustTextareaHeight();
775
+ }
776
+ };
777
+
778
+ const store = createStore("chatInput", model);
779
+
780
+ export { store };
webui/components/chat/input/progress.html CHANGED
@@ -1,3 +1,57 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a3090e2fa54cf5a9b86c50056486d5958eecbb28d88d1fb1b1d1195aeb6092f4
3
- size 2596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store as chatNavStore } from "/components/chat/navigation/chat-navigation-store.js";
5
+ </script>
6
+ </head>
7
+ <body>
8
+ <div id="progress-bar-box" x-data>
9
+ <x-extension id="chat-input-progress-start"></x-extension>
10
+
11
+ <!-- Hidden legacy progress-bar element (keeps JS updater happy, not displayed) -->
12
+ <span id="progress-bar" style="display:none;"></span>
13
+
14
+ <div id="progress-bar-right">
15
+ <h4
16
+ id="progress-bar-stop-speech"
17
+ x-data="{
18
+ speaking: window.ttsService?.isSpeaking?.() || false,
19
+ _listener: null,
20
+ init() {
21
+ this._listener = (event) => {
22
+ this.speaking = !!event?.detail?.isSpeaking;
23
+ };
24
+ window.ttsService?.addEventListener?.('statechange', this._listener);
25
+ },
26
+ cleanup() {
27
+ if (this._listener) {
28
+ window.ttsService?.removeEventListener?.('statechange', this._listener);
29
+ }
30
+ },
31
+ }"
32
+ x-init="init()"
33
+ x-destroy="cleanup()"
34
+ x-cloak
35
+ x-show="speaking"
36
+ >
37
+ <span id="stop-speech" @click="window.ttsService?.stop?.()" style="cursor: pointer" title="Stop Speech" aria-label="Stop Speech">
38
+ <x-icon class="icon" name="volume_off"></x-icon>
39
+ </span>
40
+ </h4>
41
+ <x-extension id="chat-nav-after"></x-extension>
42
+ </div>
43
+ <x-extension id="chat-input-progress-end"></x-extension>
44
+ </div>
45
+
46
+
47
+ <style>
48
+ #progress-bar-box { background-color: var(--color-panel); padding: var(--spacing-xs) var(--spacing-md) 0 var(--spacing-md); display: flex; flex-wrap: nowrap; justify-content: flex-start; align-items: center; z-index: 1001; gap: var(--spacing-sm); min-height: 0; }
49
+ #progress-bar-box > x-extension { display: contents; }
50
+ #progress-bar-box h4 { margin: 0; }
51
+ #progress-bar-right { display: flex; align-items: center; gap: var(--spacing-sm); flex-shrink: 0; margin-left: auto;}
52
+ #progress-bar-right > x-extension { display: contents; }
53
+ .shiny-text { background: linear-gradient(to right, var(--color-primary-dark) 20%, var(--color-text) 40%, var(--color-text) 60%, var(--color-primary-dark) 60%); background-size: 200% auto; color: transparent; -webkit-background-clip: text; background-clip: text; animation: shine 1s linear infinite; }
54
+ @keyframes shine { to { background-position: -200% center; } }
55
+ </style>
56
+ </body>
57
+ </html>
webui/components/chat/message-queue/message-queue-store.js CHANGED
@@ -1,3 +1,245 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:aa736dcee1b8124b42207dd332a2c6215d54e260276bd5f42439a3baddf2b422
3
- size 6718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { store as navStore } from "/components/chat/navigation/chat-navigation-store.js";
3
+ import * as api from "/js/api.js";
4
+ import {
5
+ toastFrontendInfo,
6
+ NotificationPriority,
7
+ } from "/components/notifications/notification-store.js";
8
+ import { sleep } from "/js/sleep.js";
9
+ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
10
+
11
+ const model = {
12
+ // items: [],
13
+ get items() {
14
+ return chatsStore.selectedContext?.message_queue || [];
15
+ },
16
+
17
+ pendingItems: [], // Local pending items (uploading to queue)
18
+
19
+ _pendingAddOps: {},
20
+
21
+ _lastAddToQueuePromise: Promise.resolve(),
22
+
23
+ _getQueueScrollerEl() {
24
+ return document.querySelector(".queue-preview .queue-items");
25
+ },
26
+
27
+ scrollQueueToBottom() {
28
+ const el = this._getQueueScrollerEl();
29
+ if (!el) return;
30
+
31
+ const scroll = () => {
32
+ el.scrollTop = el.scrollHeight;
33
+ };
34
+
35
+ requestAnimationFrame(() => {
36
+ scroll();
37
+ requestAnimationFrame(scroll);
38
+ });
39
+ },
40
+
41
+ get hasQueue() {
42
+ return this.items.length > 0 || this.pendingItems.length > 0;
43
+ },
44
+
45
+ get count() {
46
+ return this.items.length + this.pendingItems.length;
47
+ },
48
+
49
+ // Combined items for display: confirmed first, then pending at the end
50
+ get allItems() {
51
+ return [...this.items, ...this.pendingItems];
52
+ },
53
+
54
+ async addToQueue(text, attachments = []) {
55
+ const context = globalThis.getContext?.();
56
+ if (!context) return false;
57
+
58
+ // Generate a temporary ID for pending item
59
+ const tempId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
60
+ const pendingItem = {
61
+ id: tempId,
62
+ text: text.substring(0, 200) || "(attachment only)",
63
+ attachments: attachments.map((a) => a.name || a.file?.name || "file"),
64
+ pending: true,
65
+ };
66
+
67
+ const controller =
68
+ typeof AbortController !== "undefined" ? new AbortController() : null;
69
+ this._pendingAddOps = {
70
+ ...this._pendingAddOps,
71
+ [tempId]: {
72
+ canceled: false,
73
+ controller,
74
+ },
75
+ };
76
+
77
+ // Add to pending immediately for UI feedback
78
+ this.pendingItems = [...this.pendingItems, pendingItem];
79
+ this.scrollQueueToBottom();
80
+
81
+ const run = async () => {
82
+ const op = this._pendingAddOps?.[tempId];
83
+ if (!op || op.canceled) {
84
+ this._pendingAddOps = { ...this._pendingAddOps };
85
+ delete this._pendingAddOps[tempId];
86
+ return false;
87
+ }
88
+
89
+ try {
90
+ let filenames = [];
91
+ if (attachments.length > 0) {
92
+ const formData = new FormData();
93
+ for (const att of attachments) {
94
+ formData.append("file", att.file || att);
95
+ }
96
+ const resp = await api.fetchApi("/upload", {
97
+ method: "POST",
98
+ body: formData,
99
+ signal: op.controller ? op.controller.signal : undefined,
100
+ });
101
+ if (resp.ok) {
102
+ const result = await resp.json();
103
+ filenames = result.filenames || [];
104
+ }
105
+ }
106
+
107
+ const resp = await api.fetchApi("/message_queue_add", {
108
+ method: "POST",
109
+ headers: {
110
+ "Content-Type": "application/json",
111
+ },
112
+ credentials: "same-origin",
113
+ body: JSON.stringify({
114
+ context,
115
+ text,
116
+ attachments: filenames,
117
+ item_id: tempId,
118
+ }),
119
+ signal: op.controller ? op.controller.signal : undefined,
120
+ });
121
+
122
+ if (!resp || !resp.ok) {
123
+ return false;
124
+ }
125
+
126
+ const response = await resp.json();
127
+
128
+ return response?.ok || false;
129
+ } catch (e) {
130
+ if (e?.name !== "AbortError") {
131
+ console.error("Failed to queue message:", e);
132
+ }
133
+ return false;
134
+ } finally {
135
+ this._pendingAddOps = { ...this._pendingAddOps };
136
+ delete this._pendingAddOps[tempId];
137
+ }
138
+ };
139
+
140
+ // Chain promises to ensure sequential execution
141
+ const previous = this._lastAddToQueuePromise || Promise.resolve();
142
+ const chained = previous.catch(() => false).then(run);
143
+ this._lastAddToQueuePromise = chained.catch(() => false);
144
+ return await chained;
145
+ },
146
+
147
+ async removeItem(itemId) {
148
+ const context = globalThis.getContext?.();
149
+ if (!context) return;
150
+
151
+ const isPending = this.pendingItems.some((p) => p.id === itemId);
152
+ if (isPending) {
153
+ const op = this._pendingAddOps?.[itemId];
154
+ if (op) {
155
+ op.canceled = true;
156
+ if (op.controller) {
157
+ op.controller.abort();
158
+ }
159
+ }
160
+ this.pendingItems = this.pendingItems.filter((p) => p.id !== itemId);
161
+ return;
162
+ }
163
+
164
+ try {
165
+ await api.callJsonApi("/message_queue_remove", {
166
+ context,
167
+ item_id: itemId,
168
+ });
169
+ } catch (e) {
170
+ console.error("Failed to remove from queue:", e);
171
+ }
172
+ },
173
+
174
+ async clearQueue() {
175
+ const context = globalThis.getContext?.();
176
+ if (!context) return;
177
+ try {
178
+ await api.callJsonApi("/message_queue_remove", { context });
179
+ } catch (e) {
180
+ console.error("Failed to clear queue:", e);
181
+ }
182
+ },
183
+
184
+ async sendItem(itemId) {
185
+ const context = globalThis.getContext?.();
186
+ if (!context) return;
187
+ try {
188
+ await api.callJsonApi("/message_queue_send", {
189
+ context,
190
+ item_id: itemId,
191
+ });
192
+ } catch (e) {
193
+ console.error("Failed to send queued message:", e);
194
+ }
195
+ },
196
+
197
+ async sendAll() {
198
+ const context = globalThis.getContext?.();
199
+ if (!context || !this.hasQueue) return;
200
+
201
+ // check for pending uploads and notify user
202
+ if (this.pendingItems.length > 0) {
203
+ await sleep(1000);
204
+ if (this.pendingItems.length > 0) {
205
+ toastFrontendInfo(
206
+ "There are pending uploads in the queue. You can wait for them to finish or remove them.",
207
+ "Pending uploads",
208
+ 3,
209
+ "pending-uploads",
210
+ NotificationPriority.NORMAL,
211
+ true,
212
+ );
213
+ return;
214
+ }
215
+ }
216
+
217
+ if (!this.hasQueue) return;
218
+ try {
219
+ navStore.scrollToBottom();
220
+ await api.callJsonApi("/message_queue_send", { context, send_all: true });
221
+ } catch (e) {
222
+ console.error("Failed to send all queued:", e);
223
+ }
224
+ },
225
+
226
+ updateFromPoll() {
227
+ // this.items = queue || [];
228
+
229
+ if (this.pendingItems.length > 0) {
230
+ const serverIds = new Set(this.items.map((i) => i.id).filter(Boolean));
231
+ this.pendingItems = this.pendingItems.filter((p) => {
232
+ if (!p.id) return true;
233
+ return !serverIds.has(p.id);
234
+ });
235
+ }
236
+ // this.scrollQueueToBottom();
237
+ },
238
+
239
+ getAttachmentUrl(filename) {
240
+ return `/api/image_get?path=/a0/usr/uploads/${encodeURIComponent(filename)}`;
241
+ },
242
+ };
243
+
244
+ const store = createStore("messageQueue", model);
245
+ export { store };
webui/components/chat/message-queue/message-queue.html CHANGED
@@ -1,3 +1,245 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a0fb9b33d9df338f506b05d012aa262ed37ed7d69e69366dd104ebe62c22b1c8
3
- size 6601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/components/chat/message-queue/message-queue-store.js";
5
+ </script>
6
+ </head>
7
+ <body>
8
+ <div x-data>
9
+ <template x-if="$store.messageQueue?.hasQueue">
10
+ <div class="queue-preview">
11
+ <div class="queue-header">
12
+ <span class="queue-title">
13
+ Queued Messages (<span x-text="$store.messageQueue.count"></span>)
14
+ </span>
15
+ <div class="queue-header-actions">
16
+ <button class="queue-header-btn" @click="$store.messageQueue.sendAll()" title="Send all">
17
+ <x-icon name="keyboard_double_arrow_right"></x-icon>
18
+ </button>
19
+ <button class="queue-header-btn delete" @click="$store.messageQueue.clearQueue()" title="Clear all">
20
+ <x-icon name="delete_sweep"></x-icon>
21
+ </button>
22
+ </div>
23
+ </div>
24
+ <div class="queue-items no-scrollbar">
25
+ <template x-for="(item, index) in $store.messageQueue.allItems" :key="item.id">
26
+ <div class="queue-item" :class="{ 'queue-item-pending': item.pending }">
27
+ <div class="queue-item-content">
28
+ <template x-if="item.pending">
29
+ <span class="queue-item-pending-icon"></span>
30
+ </template>
31
+ <template x-if="!item.pending">
32
+ <span class="queue-item-seq" x-text="index + 1"></span>
33
+ </template>
34
+ <span class="queue-item-text" x-text="item.text || '(attachment only)'"></span>
35
+ <template x-if="item.attachments?.length > 0">
36
+ <div class="queue-attachments">
37
+ <template x-for="att in item.attachments" :key="att">
38
+ <img
39
+ :src="$store.messageQueue.getAttachmentUrl(att)"
40
+ :alt="att"
41
+ class="queue-attachment-thumb"
42
+ :title="att"
43
+ />
44
+ </template>
45
+ </div>
46
+ </template>
47
+ </div>
48
+ <div class="queue-item-actions">
49
+ <button class="queue-action-btn send" @click="$store.messageQueue.sendItem(item.id)" title="Send now">
50
+ <x-icon name="send"></x-icon>
51
+ </button>
52
+ <button class="queue-action-btn delete" @click="$store.messageQueue.removeItem(item.id)" title="Remove">
53
+ <x-icon name="close"></x-icon>
54
+ </button>
55
+ </div>
56
+ </div>
57
+ </template>
58
+ </div>
59
+ </div>
60
+ </template>
61
+ </div>
62
+
63
+ <style>
64
+ .queue-preview {
65
+ border: 1px solid var(--color-border);
66
+ border-radius: 8px;
67
+ padding: var(--spacing-sm);
68
+ margin-bottom: var(--spacing-xs);
69
+ }
70
+
71
+ .queue-header {
72
+ display: flex;
73
+ justify-content: space-between;
74
+ align-items: center;
75
+ margin-bottom: var(--spacing-xs);
76
+ }
77
+
78
+ .queue-title {
79
+ font-size: var(--font-size-xs);
80
+ color: var(--color-text-secondary);
81
+ }
82
+
83
+ .queue-header-actions {
84
+ display: flex;
85
+ gap: 2px;
86
+ }
87
+
88
+ .queue-header-btn {
89
+ background: transparent;
90
+ border: none;
91
+ color: var(--color-text-secondary);
92
+ cursor: pointer;
93
+ padding: 2px;
94
+ border-radius: 4px;
95
+ display: flex;
96
+ align-items: center;
97
+ opacity: 0.6;
98
+ transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out,
99
+ box-shadow 0.1s ease-in-out;
100
+ }
101
+
102
+ .queue-header-btn:hover {
103
+ opacity: 1;
104
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent);
105
+ }
106
+
107
+ .queue-header-btn:active {
108
+ opacity: 0.5;
109
+ box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.06);
110
+ }
111
+
112
+ .queue-header-btn .material-symbols-outlined {
113
+ font-size: var(--font-size-large);
114
+ }
115
+
116
+ .queue-items {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 2px;
120
+ max-height: 120px;
121
+ overflow-y: auto;
122
+ }
123
+
124
+ .queue-item {
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: space-between;
128
+ gap: var(--spacing-sm);
129
+ padding: 6px 8px;
130
+ border-radius: 4px;
131
+ cursor: default;
132
+ }
133
+
134
+ .queue-item:hover {
135
+ background-color: var(--color-background-hover);
136
+ }
137
+
138
+ .queue-item-content {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: var(--spacing-sm);
142
+ flex: 1;
143
+ min-width: 0;
144
+ }
145
+
146
+ .queue-item-seq {
147
+ display: inline-flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ width: 1rem;
151
+ height: 1rem;
152
+ font-size: 0.5rem;
153
+ color: var(--color-text-secondary);
154
+ background: var(--color-background);
155
+ border-radius: 50%;
156
+ }
157
+
158
+ .queue-item-text {
159
+ font-size: var(--font-size-small);
160
+ color: var(--color-text);
161
+ white-space: nowrap;
162
+ overflow: hidden;
163
+ text-overflow: ellipsis;
164
+ flex: 1;
165
+ min-width: 0;
166
+ }
167
+
168
+ .queue-attachments {
169
+ display: flex;
170
+ gap: 4px;
171
+ flex-shrink: 0;
172
+ }
173
+
174
+ .queue-attachment-thumb {
175
+ width: 20px;
176
+ height: 20px;
177
+ object-fit: contain;
178
+ border-radius: 3px;
179
+ border: 1px solid var(--color-border);
180
+ }
181
+
182
+ .queue-item-actions {
183
+ display: flex;
184
+ gap: 2px;
185
+ flex-shrink: 0;
186
+ }
187
+
188
+ .device-pointer .queue-item-actions {
189
+ opacity: 0;
190
+ visibility: hidden;
191
+ pointer-events: none;
192
+ transition: opacity 0.15s;
193
+ }
194
+
195
+ .device-pointer .queue-item:hover .queue-item-actions {
196
+ opacity: 1;
197
+ visibility: visible;
198
+ pointer-events: auto;
199
+ }
200
+
201
+ .device-touch .queue-item-actions {
202
+ opacity: 1;
203
+ visibility: visible;
204
+ pointer-events: auto;
205
+ }
206
+
207
+ .queue-action-btn {
208
+ background: transparent;
209
+ border: none;
210
+ color: var(--color-primary);
211
+ cursor: pointer;
212
+ padding: 4px;
213
+ border-radius: 4px;
214
+ display: flex;
215
+ align-items: center;
216
+ }
217
+
218
+ .queue-action-btn .material-symbols-outlined {
219
+ font-size: var(--font-size-smaller);
220
+ }
221
+
222
+ .queue-item-pending {
223
+ opacity: 0.6;
224
+ }
225
+
226
+ .queue-item-pending .queue-action-btn.send {
227
+ visibility: hidden !important;
228
+ pointer-events: none !important;
229
+ }
230
+
231
+ .queue-item-pending-icon {
232
+ width: 1rem;
233
+ height: 1rem;
234
+ border: 2px solid var(--color-border);
235
+ border-top-color: var(--color-primary);
236
+ border-radius: 50%;
237
+ animation: pending-spin 0.8s linear infinite;
238
+ }
239
+
240
+ @keyframes pending-spin {
241
+ to { transform: rotate(360deg); }
242
+ }
243
+ </style>
244
+ </body>
245
+ </html>
webui/components/chat/model-gate-store.js CHANGED
@@ -1,3 +1,233 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:508d0f27865b8aa46fcb8c99788caba98a1b5258d6f9c8569a3e46a75eb8c53d
3
- size 7685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { callJsonApi } from "/js/api.js";
3
+ import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4
+ import { store as onboardingStore } from "/plugins/_onboarding/webui/onboarding-store.js";
5
+ import { toastFrontendError } from "/components/notifications/notification-store.js";
6
+
7
+ const ONBOARDING_MODAL_PATH = "/plugins/_onboarding/webui/onboarding.html";
8
+ const STORAGE_KEY = "a0:model-gate-pending:v1";
9
+ const SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2;
10
+
11
+ export const store = createStore("modelGate", {
12
+ active: false,
13
+ connected: false,
14
+ connectedLabel: "",
15
+ accountConnected: false,
16
+ accountLabel: "",
17
+ pending: null,
18
+ gateMessageId: "",
19
+ choice: "",
20
+ dispatching: false,
21
+ _initialized: false,
22
+
23
+ get introText() {
24
+ if (this.connected) {
25
+ return `Model connected: ${this.connectedLabel || "ready"}`;
26
+ }
27
+ if (this.accountConnected) {
28
+ return `${this.accountLabel || "Your AI account"} is connected. Choose Main and Utility models and I'll answer right away.`;
29
+ }
30
+ return "I'm ready to work on this — I just need a model to think with. Pick one and I'll answer right away.";
31
+ },
32
+
33
+ init() {
34
+ if (this._initialized) return;
35
+ this._initialized = true;
36
+ this.restorePending();
37
+ document.addEventListener("onboarding-configured", () => {
38
+ void this.dispatchPendingIfConfigured();
39
+ });
40
+ document.addEventListener("model-configured", () => {
41
+ void this.dispatchPendingIfConfigured();
42
+ });
43
+ document.addEventListener("model-setup-changed", () => {
44
+ void this.dispatchPendingIfConfigured();
45
+ });
46
+ document.addEventListener("modal-closed", () => {
47
+ if (this.active && !this.connected) {
48
+ this.choice = "";
49
+ this.savePending();
50
+ void this.dispatchPendingIfConfigured();
51
+ }
52
+ });
53
+ },
54
+
55
+ async canSendToModel() {
56
+ try {
57
+ const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
58
+ this.applyModelStatus(data);
59
+ if (!data?.model_configured) await this.refreshConnectedAccountState();
60
+ return !!data?.model_configured;
61
+ } catch (error) {
62
+ console.error("Could not check model configuration:", error);
63
+ return true;
64
+ }
65
+ },
66
+
67
+ async refreshConnectedAccountState() {
68
+ try {
69
+ const { store: oauthConfigStore } = await import("/plugins/_oauth/webui/oauth-config-store.js");
70
+ await oauthConfigStore.loadStatus();
71
+ const account = oauthConfigStore.connectedProviderCards()[0] || null;
72
+ this.accountConnected = Boolean(account);
73
+ this.accountLabel = account?.short_name || account?.display_name || account?.provider_id || "";
74
+ return this.accountConnected;
75
+ } catch (error) {
76
+ console.error("Could not check connected accounts:", error);
77
+ this.accountConnected = false;
78
+ this.accountLabel = "";
79
+ return false;
80
+ }
81
+ },
82
+
83
+ applyModelStatus(data = {}) {
84
+ modelConfigStore.modelConfigured = !!data.model_configured;
85
+ modelConfigStore.modelConfiguredLabel = data.model_configured_label || "";
86
+ this.connectedLabel = data.model_configured_label || this.connectedLabel || "";
87
+ },
88
+
89
+ start({ message, attachments, messageId, context }) {
90
+ this.init();
91
+ this.active = true;
92
+ this.connected = false;
93
+ this.pending = { message, attachments, messageId, context };
94
+ this.gateMessageId = this.gateMessageId || `model-gate-${messageId}`;
95
+ this.savePending();
96
+ },
97
+
98
+ syntheticMessages(currentContext) {
99
+ this.init();
100
+ if (!this.active || !this.pending || this.pending.context !== currentContext) return [];
101
+ return [
102
+ {
103
+ no: SYNTHETIC_MESSAGE_NO_BASE,
104
+ id: this.pending.messageId,
105
+ type: "user",
106
+ content: this.pending.message,
107
+ kvps: { attachments: this.pending.attachments },
108
+ },
109
+ {
110
+ no: SYNTHETIC_MESSAGE_NO_BASE + 1,
111
+ id: this.gateMessageId,
112
+ type: "model_setup_gate",
113
+ },
114
+ ];
115
+ },
116
+
117
+ mergeSyntheticMessages(logs, currentContext) {
118
+ this.init();
119
+ const synthetic = this.syntheticMessages(currentContext);
120
+ return synthetic.length ? [...(logs || []), ...synthetic] : logs;
121
+ },
122
+
123
+ onCardCreate() {
124
+ this.init();
125
+ void this.dispatchPendingIfConfigured();
126
+ },
127
+
128
+ openOnboarding(choice) {
129
+ this.choice = ["local", "account"].includes(choice) ? choice : "cloud";
130
+ this.savePending();
131
+ onboardingStore.presetMode = this.choice;
132
+ const modalPromise = window.openModal?.(ONBOARDING_MODAL_PATH);
133
+ void Promise.resolve(modalPromise).then(() => this.dispatchPendingIfConfigured());
134
+ },
135
+
136
+ async openAdvancedModelConfiguration() {
137
+ try {
138
+ const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
139
+ await modelConfigStore.openPresetEditor(
140
+ data?.selected_preset || data?.configured_preset || "Default"
141
+ );
142
+ await this.dispatchPendingIfConfigured();
143
+ } catch (error) {
144
+ console.error("Could not open model preset editor:", error);
145
+ void toastFrontendError(
146
+ error?.message || "Could not open model presets.",
147
+ "Advanced model configuration"
148
+ );
149
+ }
150
+ },
151
+
152
+ async dispatchPendingIfConfigured() {
153
+ this.init();
154
+ if (this.dispatching || !this.pending) return;
155
+ const configured = await this.canSendToModel();
156
+ if (!configured) return;
157
+
158
+ const pending = this.pending;
159
+ this.pending = null;
160
+ this.connected = true;
161
+ this.dispatching = true;
162
+ this.clearSavedPending();
163
+ try {
164
+ await globalThis.sendMessage?.({
165
+ bypassModelGate: true,
166
+ skipExtensions: true,
167
+ preserveInput: true,
168
+ message: pending.message,
169
+ attachments: pending.attachments,
170
+ messageId: pending.messageId,
171
+ context: pending.context,
172
+ });
173
+ } finally {
174
+ this.dispatching = false;
175
+ }
176
+ },
177
+
178
+ savePending() {
179
+ const message = String(this.pending?.message || "").trim();
180
+ const context = String(this.pending?.context || "");
181
+ const messageId = String(this.pending?.messageId || "");
182
+ const attachments = Array.isArray(this.pending?.attachments) ? this.pending.attachments : [];
183
+ if (!message || !context || !messageId || attachments.length) {
184
+ this.clearSavedPending();
185
+ return;
186
+ }
187
+
188
+ try {
189
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
190
+ pending: { message, attachments: [], messageId, context },
191
+ gateMessageId: this.gateMessageId || `model-gate-${messageId}`,
192
+ choice: this.choice,
193
+ }));
194
+ } catch (_error) {
195
+ // Session storage can be unavailable in private or locked-down browser modes.
196
+ }
197
+ },
198
+
199
+ restorePending() {
200
+ if (this.pending) return;
201
+
202
+ let saved = null;
203
+ try {
204
+ saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null");
205
+ } catch (_error) {
206
+ this.clearSavedPending();
207
+ return;
208
+ }
209
+
210
+ const message = String(saved?.pending?.message || "").trim();
211
+ const context = String(saved?.pending?.context || "");
212
+ const messageId = String(saved?.pending?.messageId || "");
213
+ if (!message || !context || !messageId) {
214
+ this.clearSavedPending();
215
+ return;
216
+ }
217
+
218
+ this.active = true;
219
+ this.connected = false;
220
+ this.pending = { message, attachments: [], messageId, context };
221
+ this.gateMessageId = String(saved?.gateMessageId || `model-gate-${messageId}`);
222
+ this.choice = ["local", "cloud", "account"].includes(saved?.choice) ? saved.choice : "";
223
+ },
224
+
225
+ clearSavedPending() {
226
+ try {
227
+ sessionStorage.removeItem(STORAGE_KEY);
228
+ } catch (_error) {
229
+ // Ignore unavailable storage; the in-memory gate state is still authoritative.
230
+ }
231
+ },
232
+
233
+ });
webui/components/chat/model-setup-gate.html CHANGED
@@ -1,3 +1,203 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:84203c0efe43657c9506ab678938f133cfd2d2738363cf77449da3a106558c61
3
- size 6522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store as modelGateStore } from "/components/chat/model-gate-store.js";
5
+ </script>
6
+ </head>
7
+ <body>
8
+ <div x-data>
9
+ <template x-if="$store.modelGate">
10
+ <section class="model-gate" x-create="$store.modelGate.onCardCreate()">
11
+ <template x-if="$store.modelGate.connected">
12
+ <div class="model-gate-chip">
13
+ <x-icon aria-hidden="true" name="check_circle"></x-icon>
14
+ <span x-text="$store.modelGate.introText"></span>
15
+ </div>
16
+ </template>
17
+
18
+ <template x-if="!$store.modelGate.connected">
19
+ <div class="model-gate-body">
20
+ <p class="model-gate-intro" x-text="$store.modelGate.introText"></p>
21
+
22
+ <div class="model-gate-card">
23
+ <div class="model-gate-fork is-single" x-show="$store.modelGate.accountConnected">
24
+ <button type="button"
25
+ class="model-gate-option"
26
+ @click="$store.modelGate.openAdvancedModelConfiguration()">
27
+ <span class="model-gate-option-title">
28
+ <x-icon aria-hidden="true" name="tune"></x-icon>
29
+ <span>Choose models</span>
30
+ </span>
31
+ <span class="model-gate-option-sub">Select Main and Utility models</span>
32
+ </button>
33
+ </div>
34
+
35
+ <div class="model-gate-fork" x-show="!$store.modelGate.accountConnected">
36
+ <button type="button"
37
+ class="model-gate-option"
38
+ :class="{ selected: $store.modelGate.choice === 'cloud' }"
39
+ @click="$store.modelGate.openOnboarding('cloud')">
40
+ <span class="model-gate-option-title">
41
+ <x-icon aria-hidden="true" name="cloud"></x-icon>
42
+ <span>Cloud provider</span>
43
+ </span>
44
+ <span class="model-gate-option-sub">Connect with an API key</span>
45
+ </button>
46
+ <button type="button"
47
+ class="model-gate-option"
48
+ :class="{ selected: $store.modelGate.choice === 'account' }"
49
+ @click="$store.modelGate.openOnboarding('account')">
50
+ <span class="model-gate-option-title">
51
+ <x-icon aria-hidden="true" name="account_circle"></x-icon>
52
+ <span>AI account</span>
53
+ </span>
54
+ <span class="model-gate-option-sub">Codex, Copilot, Gemini, Grok</span>
55
+ </button>
56
+ <button type="button"
57
+ class="model-gate-option"
58
+ :class="{ selected: $store.modelGate.choice === 'local' }"
59
+ @click="$store.modelGate.openOnboarding('local')">
60
+ <span class="model-gate-option-title">
61
+ <x-icon aria-hidden="true" name="memory"></x-icon>
62
+ <span>Local model</span>
63
+ </span>
64
+ <span class="model-gate-option-sub">Runs on your machine</span>
65
+ </button>
66
+ </div>
67
+ </div>
68
+
69
+ <p class="model-gate-footnote">
70
+ <span>Your message sends automatically once a model is connected.</span>
71
+ <button type="button" @click="$store.modelGate.openAdvancedModelConfiguration()">Advanced model configuration</button>
72
+ </p>
73
+ </div>
74
+ </template>
75
+ </section>
76
+ </template>
77
+ </div>
78
+
79
+ <style>
80
+ .model-gate {
81
+ width: min(100%, 36rem);
82
+ color: var(--color-text);
83
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
84
+ }
85
+
86
+ .model-gate-body {
87
+ display: grid;
88
+ gap: 0.65rem;
89
+ container-type: inline-size;
90
+ }
91
+
92
+ .model-gate-intro {
93
+ margin: 0;
94
+ color: color-mix(in srgb, var(--color-text) 84%, transparent);
95
+ line-height: 1.45;
96
+ }
97
+
98
+ .model-gate-card {
99
+ display: grid;
100
+ gap: 0.75rem;
101
+ }
102
+
103
+ .model-gate-fork {
104
+ display: flex;
105
+ gap: 0.6rem;
106
+ }
107
+
108
+ .model-gate-fork .model-gate-option {
109
+ flex: 1 1 0;
110
+ min-width: 0;
111
+ }
112
+
113
+ @container (max-width: 30rem) {
114
+ .model-gate-fork {
115
+ flex-direction: column;
116
+ }
117
+ }
118
+
119
+ .model-gate-option {
120
+ border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
121
+ border-radius: 8px;
122
+ background: color-mix(in srgb, var(--color-background) 52%, transparent);
123
+ color: inherit;
124
+ font: inherit;
125
+ text-align: left;
126
+ cursor: pointer;
127
+ }
128
+
129
+ .model-gate-option {
130
+ display: grid;
131
+ gap: 0.25rem;
132
+ padding: 0.72rem;
133
+ }
134
+
135
+ .model-gate-option:hover,
136
+ .model-gate-option:focus-visible,
137
+ .model-gate-option.selected {
138
+ border-color: color-mix(in srgb, var(--color-border) 86%, var(--color-text) 14%);
139
+ background: color-mix(in srgb, var(--color-background-hover) 60%, var(--color-panel));
140
+ outline: none;
141
+ }
142
+
143
+ .model-gate-option-title {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ gap: 0.4rem;
147
+ font-weight: 560;
148
+ }
149
+
150
+ .model-gate-option-title .material-symbols-outlined {
151
+ font-size: 1.15rem;
152
+ color: color-mix(in srgb, var(--color-text) 72%, transparent);
153
+ }
154
+
155
+ .model-gate-option-sub,
156
+ .model-gate-footnote {
157
+ color: color-mix(in srgb, var(--color-text) 58%, transparent);
158
+ font-size: 0.78rem;
159
+ line-height: 1.35;
160
+ }
161
+
162
+ .model-gate-footnote {
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: space-between;
166
+ gap: 0.7rem;
167
+ flex-wrap: wrap;
168
+ }
169
+
170
+ .model-gate-footnote button {
171
+ border: 0;
172
+ background: transparent;
173
+ color: color-mix(in srgb, #6f8cff 82%, var(--color-text));
174
+ font: inherit;
175
+ font-size: 0.8rem;
176
+ cursor: pointer;
177
+ padding: 0;
178
+ }
179
+
180
+ .model-gate-chip {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 0.4rem;
184
+ border: 1px solid color-mix(in srgb, #7bc995 44%, var(--color-border));
185
+ border-radius: 999px;
186
+ padding: 0.35rem 0.65rem;
187
+ color: color-mix(in srgb, var(--color-text) 88%, #7bc995);
188
+ background: color-mix(in srgb, #7bc995 10%, var(--color-panel));
189
+ font-size: 0.84rem;
190
+ }
191
+
192
+ .model-gate-chip .material-symbols-outlined {
193
+ font-size: 1rem;
194
+ }
195
+
196
+ @media (max-width: 480px) {
197
+ .model-gate-fork {
198
+ flex-direction: column;
199
+ }
200
+ }
201
+ </style>
202
+ </body>
203
+ </html>
webui/components/chat/navigation/chat-navigation-store.js CHANGED
@@ -1,3 +1,127 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:319bdd51040b0b65ab5219f34d951556736c799c5755b942ff0f527707d18046
3
- size 4661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import {
3
+ getMessageWindowState,
4
+ loadAdjacentMessageWindow,
5
+ scrollMessageWindowToEdge,
6
+ } from "/js/messages.js";
7
+
8
+ const model = {
9
+ // Configuration
10
+ scrollMargin: 60,
11
+ prevTolerance: 35,
12
+ nextTolerance: 5,
13
+
14
+ init() {
15
+ // Any initialization if needed
16
+ },
17
+
18
+ async scrollToTop() {
19
+ await scrollMessageWindowToEdge("start");
20
+ },
21
+
22
+ async scrollToBottom() {
23
+ await scrollMessageWindowToEdge("end");
24
+ },
25
+
26
+ async scrollToPrevUserMessage() {
27
+ const scroller = this._getChatHistoryEl();
28
+ if (!scroller) return;
29
+
30
+ const positions = this._getUserMessagePositions(scroller);
31
+ const scrollerRect = scroller.getBoundingClientRect();
32
+
33
+ const prevThreshold = this.scrollMargin - this.prevTolerance; // 25px
34
+
35
+ const currentIndex = positions.findIndex((p) => {
36
+ const relativeTop = p.el.getBoundingClientRect().top - scrollerRect.top;
37
+ return relativeTop >= prevThreshold;
38
+ });
39
+
40
+ if (currentIndex > 0) {
41
+ // Go to previous message
42
+ positions[currentIndex - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
43
+ } else if (currentIndex === 0) {
44
+ if (getMessageWindowState().hasOlder) {
45
+ await loadAdjacentMessageWindow("older");
46
+ this._scrollToLastUserAboveThreshold(scroller, prevThreshold);
47
+ } else {
48
+ scroller.scrollTo({ top: 0, behavior: "instant" });
49
+ }
50
+ } else if (currentIndex === -1 && positions.length > 0) {
51
+ // All messages are above the threshold (scrolled past), scroll to bottom
52
+ positions[positions.length - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
53
+ } else if (positions.length === 0 && getMessageWindowState().hasOlder) {
54
+ await loadAdjacentMessageWindow("older");
55
+ this._scrollToLastUserAboveThreshold(scroller, prevThreshold);
56
+ }
57
+ },
58
+
59
+ async scrollToNextUserMessage() {
60
+ const scroller = this._getChatHistoryEl();
61
+ if (!scroller) return;
62
+
63
+ const positions = this._getUserMessagePositions(scroller);
64
+ const scrollerRect = scroller.getBoundingClientRect();
65
+
66
+ const nextThreshold = this.scrollMargin + this.nextTolerance; // 65px
67
+
68
+ // Find first message below the threshold
69
+ const targetIndex = positions.findIndex((p) => {
70
+ const relativeTop = p.el.getBoundingClientRect().top - scrollerRect.top;
71
+ return relativeTop > nextThreshold;
72
+ });
73
+
74
+ if (targetIndex !== -1) {
75
+ // Go to that message
76
+ positions[targetIndex].el.scrollIntoView({ block: "start", behavior: "smooth" });
77
+ } else {
78
+ if (getMessageWindowState().hasNewer) {
79
+ await loadAdjacentMessageWindow("newer");
80
+ this._scrollToFirstUserBelowThreshold(scroller, nextThreshold);
81
+ } else {
82
+ await this.scrollToBottom();
83
+ }
84
+ }
85
+ },
86
+
87
+ _scrollToLastUserAboveThreshold(scroller, threshold) {
88
+ const scrollerRect = scroller.getBoundingClientRect();
89
+ const positions = this._getUserMessagePositions(scroller);
90
+ const candidates = positions.filter(
91
+ ({ el }) => el.getBoundingClientRect().top - scrollerRect.top < threshold
92
+ );
93
+ candidates.at(-1)?.el.scrollIntoView({ block: "start", behavior: "smooth" });
94
+ },
95
+
96
+ _scrollToFirstUserBelowThreshold(scroller, threshold) {
97
+ const scrollerRect = scroller.getBoundingClientRect();
98
+ const target = this._getUserMessagePositions(scroller).find(
99
+ ({ el }) => el.getBoundingClientRect().top - scrollerRect.top > threshold
100
+ );
101
+ target?.el.scrollIntoView({ block: "start", behavior: "smooth" });
102
+ },
103
+
104
+ // Helpers
105
+ _getChatHistoryEl() {
106
+ return document.getElementById("chat-history");
107
+ },
108
+
109
+ _getUserMessagePositions(scroller) {
110
+ const userMessageEls = Array.from(
111
+ scroller.querySelectorAll(".message-container.user-container")
112
+ );
113
+ // Helper for getElementTopInScroller
114
+ const getTop = (el) => {
115
+ const elRect = el.getBoundingClientRect();
116
+ const scrollerRect = scroller.getBoundingClientRect();
117
+ return elRect.top - scrollerRect.top + scroller.scrollTop;
118
+ };
119
+
120
+ return userMessageEls
121
+ .map((el) => ({ el, top: getTop(el) }))
122
+ .sort((a, b) => a.top - b.top);
123
+ }
124
+ };
125
+
126
+ const store = createStore("chatNavigation", model);
127
+ export { store };
webui/components/chat/top-section/chat-top-store.js CHANGED
@@ -1,3 +1,13 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:b72133213443c983195c6cd378516d26c74835719dec7b8e2a16603a9a514b1b
3
- size 334
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+
3
+ // define the model object holding data and functions
4
+ const model = {
5
+ connected: false,
6
+ progressActive: false, // true when progress bar is active
7
+ };
8
+
9
+ // convert it to alpine store
10
+ const store = createStore("chatTop", model);
11
+
12
+ // export for use in other files
13
+ export { store };
webui/components/chat/top-section/chat-top.html CHANGED
@@ -1,3 +1,42 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:80a1150109c8e4c7fbb4a5be3d759536a6c1bb813af1edae4c13ef1945fc7074
3
- size 1579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+
3
+ <head>
4
+ <title>Example component or modal</title>
5
+
6
+ <!-- Import the alpine store -->
7
+ <script type="module">
8
+ import { store } from "/components/chat/top-section/chat-top-store.js";
9
+ import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
10
+ </script>
11
+ </head>
12
+
13
+ <body>
14
+
15
+ <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
16
+ <div x-data>
17
+ <template x-if="$store.chatTop">
18
+ <!-- Time and Date -->
19
+ <div id="time-date-container">
20
+ <x-extension id="chat-top-start"></x-extension>
21
+ <div id="time-date" x-show="$store.preferences.isUiControlVisible('time')"></div>
22
+ <div x-show="$store.preferences.isUiControlVisible('connectionStatus')">
23
+ <x-component path="sync/sync-status.html"></x-component>
24
+ </div>
25
+ <!-- Notification Toggle positioned next to time-date -->
26
+ <x-component path="notifications/notification-icons.html"></x-component>
27
+ <!-- Project Selector -->
28
+ <div x-show="$store.preferences.isUiControlVisible('projectSelector')">
29
+ <x-component path="projects/project-selector.html"></x-component>
30
+ </div>
31
+ <x-extension id="chat-top-end"></x-extension>
32
+ </div>
33
+ </template>
34
+ </div>
35
+
36
+ <style>
37
+ #time-date-container > x-extension { display: contents; }
38
+ </style>
39
+
40
+ </body>
41
+
42
+ </html>
webui/components/dropdown/AGENTS.md CHANGED
@@ -1,3 +1,26 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0f3838921b0e98ec0b562a2a7d32f5e9f5b962cd6f4e4ac9b6cd9ac2e9243caf
3
- size 582
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dropdown Component DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the shared dropdown component used by WebUI controls.
6
+
7
+ ## Ownership
8
+
9
+ - `dropdown.html` owns dropdown markup, behavior hooks, and component-local styling.
10
+
11
+ ## Local Contracts
12
+
13
+ - Keep keyboard, focus, and click-outside behavior compatible with existing callers.
14
+ - Avoid caller-specific assumptions in the shared dropdown.
15
+
16
+ ## Work Guidance
17
+
18
+ - Prefer component attributes and slots over hardcoded option sets.
19
+
20
+ ## Verification
21
+
22
+ - Smoke-test each caller that uses the dropdown after behavior changes.
23
+
24
+ ## Child DOX Index
25
+
26
+ No child DOX files.
webui/components/dropdown/dropdown.html CHANGED
@@ -1,3 +1,69 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:e3a1b08991339508618332fc2909fd59fe3c6ea8958c1e46103f8303eebbf0ed
3
- size 1810
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Reusable Dropdown Component
3
+
4
+ Usage:
5
+ <x-component data-component="ui/dropdown" data-props='{"id": "my-dropdown"}'>
6
+ <template x-slot:trigger>
7
+ <button>Click me</button>
8
+ </template>
9
+ <template x-slot:menu>
10
+ <button class="dropdown-item" @click="...">Item 1</button>
11
+ <div class="dropdown-separator"></div>
12
+ <button class="dropdown-item" @click="...">Item 2</button>
13
+ </template>
14
+ </x-component>
15
+
16
+ Or use the simpler inline approach with x-data:
17
+ <div class="dropdown" x-data="{ open: false }" @click.outside="open = false">
18
+ <button class="dropdown-trigger" @click="open = !open">...</button>
19
+ <div class="dropdown-menu" x-show="open" x-transition>...</div>
20
+ </div>
21
+ -->
22
+ <html>
23
+ <head>
24
+ <!-- Dropdown uses global styles from index.css -->
25
+ </head>
26
+ <body>
27
+ <div
28
+ class="dropdown"
29
+ x-data="{
30
+ open: false,
31
+ toggle() { this.open = !this.open; },
32
+ close() { this.open = false; }
33
+ }"
34
+ @click.outside="close()"
35
+ @keydown.escape.window="close()"
36
+ >
37
+ <!-- Trigger slot -->
38
+ <div class="dropdown-trigger-wrapper" @click="toggle()">
39
+ <slot name="trigger">
40
+ <button class="dropdown-trigger" type="button">
41
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
42
+ <path d="M6 9l6 6 6-6"/>
43
+ </svg>
44
+ </button>
45
+ </slot>
46
+ </div>
47
+
48
+ <!-- Menu slot -->
49
+ <div
50
+ class="dropdown-menu"
51
+ x-show="open"
52
+ @click="close()"
53
+ >
54
+ <slot name="menu">
55
+ <!-- Default empty menu -->
56
+ </slot>
57
+ </div>
58
+ </div>
59
+
60
+ <style>
61
+ /* Component-specific styles (layout helpers) */
62
+ .dropdown-trigger-wrapper {
63
+ display: contents;
64
+ }
65
+
66
+ </style>
67
+ </body>
68
+ </html>
69
+
webui/components/messages/AGENTS.md CHANGED
@@ -1,3 +1,38 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0c8e79e3f5634742fab1915654221fd49f925c9ea29d9ae79590dd1dc9543722
3
- size 2884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Message Components DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own message rendering helpers, action buttons, process groups, and resize behavior.
6
+
7
+ ## Ownership
8
+
9
+ - `action-buttons/` owns simple message action controls.
10
+ - `process-group/` owns grouped process-step DOM and styling helpers.
11
+ - `resize/` owns message resize state.
12
+
13
+ ## Local Contracts
14
+
15
+ - Keep message DOM helpers compatible with extension points that modify rendered messages.
16
+ - Sanitize or safely render model/user-provided content through shared rendering paths.
17
+ - Avoid layout shifts that break long-running message streaming.
18
+ - Keep message action chrome out of text selection so copy/paste captures message content without button labels or icons.
19
+ - Reconcile standard action buttons in place during streamed updates so hover, focus, tooltips, and click feedback survive while handlers receive the latest message data; preserve extension-owned buttons in the same action bar.
20
+ - Order standard message actions as Detail, Copy, then Speak; omit unavailable actions without changing the relative order of the remaining controls. Plugin-rendered message actions must follow the same order.
21
+ - Keep collapsed process-step detail text out of the DOM; opening a step may materialize its current cached log data and collapsing it must discard that heavy detail again without removing extension action hooks.
22
+ - Preference-driven process detail modes must await the same materialization path as manual expansion and accept an explicit chat-history target for off-screen window staging. `STEP` opens only the current non-utility step at the live tail; historical windows must not invent a current step at their boundary.
23
+ - Keep oversized standalone replay bodies and key/value tables in a bounded preview state until the user expands them; collapsing must remove the full body again.
24
+ - Message-window boundaries must not split process groups. Groups with more than 50 steps initially render their newest 50 steps and prepend earlier steps in 50-step increments through the group-local `Show more` control while retaining stable full-group header metrics.
25
+ - The process-group `Show more` paging control uses the same understated, non-underlined typography and hover-opacity treatment as message-body expansion controls.
26
+ - A root response may attach only to a substantive process render unit. Utility-prefixed units remain visible even while utility steps are hidden; standalone utility-only groups remain separate and hidden while utility messages are disabled, and completed groups must not absorb later utility records. Determine this from full-log render metadata, not partially mounted DOM children.
27
+
28
+ ## Work Guidance
29
+
30
+ - Coordinate changes with `webui/js/messages.js` and frontend extension hooks.
31
+
32
+ ## Verification
33
+
34
+ - Smoke-test message rendering, action buttons, process groups, and resizing after changes.
35
+
36
+ ## Child DOX Index
37
+
38
+ No child DOX files.
webui/components/messages/action-buttons/simple-action-buttons.css CHANGED
@@ -1,3 +1,153 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:ba3db6841e04158cf6798cfebc7bbc878cd885a9935ba6e64938371327ef8f3c
3
- size 3627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===========================================
2
+ Action Buttons - Icon-only copy/speak/view details
3
+ Show on hover for pointer devices, always visible on touch
4
+ =========================================== */
5
+
6
+ .step-action-buttons {
7
+ display: flex;
8
+ flex-direction: row;
9
+ align-items: center;
10
+ gap: var(--spacing-sm);
11
+ padding: var(--spacing-sm) 0;
12
+ opacity: 0;
13
+ transition: opacity 0.2s ease-in-out;
14
+ pointer-events: none;
15
+ user-select: none;
16
+ }
17
+
18
+ .step-action-buttons .expand-btn {
19
+ display: none;
20
+ align-items: center;
21
+ background: transparent;
22
+ border: none;
23
+ color: var(--color-text-muted);
24
+ font-family: "Rubik", Arial, Helvetica, sans-serif;
25
+ font-size: var(--font-size-xs);
26
+ cursor: pointer;
27
+ padding: var(--spacing-xs) 0;
28
+ opacity: 0.7;
29
+ transition: opacity 0.15s ease;
30
+ }
31
+
32
+ .step-action-buttons .expand-btn:hover {
33
+ opacity: 1;
34
+ }
35
+
36
+ .step-action-buttons .action-button,
37
+ .action-button {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ background: transparent;
42
+ border: none;
43
+ padding: 2px;
44
+ cursor: pointer;
45
+ }
46
+
47
+ .step-action-buttons .action-button .material-symbols-outlined,
48
+ .action-button .material-symbols-outlined {
49
+ font-size: 0.85rem;
50
+ line-height: 1;
51
+ color: var(--color-message-text);
52
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
53
+ transition: color 0.15s ease;
54
+ }
55
+
56
+ .step-action-buttons .action-button:hover .material-symbols-outlined,
57
+ .action-button:hover .material-symbols-outlined {
58
+ color: var(--color-text);
59
+ }
60
+
61
+ /* Success state */
62
+ .step-action-buttons .action-button.success .material-symbols-outlined,
63
+ .action-button.success .material-symbols-outlined {
64
+ color: #4CAF50;
65
+ font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
66
+ }
67
+
68
+ /* Error state */
69
+ .step-action-buttons .action-button.error .material-symbols-outlined,
70
+ .action-button.error .material-symbols-outlined {
71
+ color: var(--color-accent);
72
+ font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 20;
73
+ }
74
+
75
+ .show-more-btn,
76
+ .show-less-btn {
77
+ display: none;
78
+ }
79
+
80
+ .message.message-collapsible.has-overflow:not(.expanded) > .step-action-buttons {
81
+ opacity: 1;
82
+ pointer-events: auto;
83
+ }
84
+
85
+ .message.message-collapsible.has-overflow:not(.expanded)
86
+ > .step-action-buttons
87
+ .show-more-btn {
88
+ display: inline-flex;
89
+ pointer-events: auto;
90
+ }
91
+
92
+ .device-pointer
93
+ .message.message-collapsible.has-overflow:not(.expanded)
94
+ > .step-action-buttons
95
+ .action-button {
96
+ opacity: 0;
97
+ pointer-events: none;
98
+ }
99
+
100
+ .device-pointer
101
+ .message.message-collapsible.has-overflow:not(.expanded):hover
102
+ > .step-action-buttons
103
+ .action-button {
104
+ opacity: 1;
105
+ pointer-events: auto;
106
+ }
107
+
108
+ .device-pointer
109
+ .message.message-collapsible.has-overflow.expanded:hover
110
+ > .step-action-buttons
111
+ .show-less-btn {
112
+ display: inline-flex;
113
+ pointer-events: auto;
114
+ }
115
+
116
+ .device-touch
117
+ .message.message-collapsible.has-overflow.expanded
118
+ > .step-action-buttons
119
+ .show-less-btn {
120
+ display: inline-flex;
121
+ pointer-events: auto;
122
+ }
123
+
124
+ /* User messages: right-aligned */
125
+ .message-user .step-action-buttons {
126
+ justify-content: flex-end;
127
+ width: 100%;
128
+ }
129
+
130
+ .message-user .step-action-buttons .expand-btn {
131
+ order: 1;
132
+ }
133
+
134
+
135
+
136
+ /* ===========================================
137
+ Hover behavior - Pointer devices
138
+ =========================================== */
139
+
140
+ .device-pointer .step-action-buttons:hover,
141
+ .device-pointer :hover > .step-action-buttons {
142
+ opacity: 1;
143
+ pointer-events: auto;
144
+ }
145
+
146
+ /* ===========================================
147
+ Touch devices - Always visible
148
+ =========================================== */
149
+
150
+ .device-touch .step-action-buttons {
151
+ opacity: 1;
152
+ pointer-events: auto;
153
+ }
webui/components/messages/action-buttons/simple-action-buttons.js CHANGED
@@ -1,3 +1,134 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:906e3cf12b86ff41aa8ee6c3059a1dffb98c258f3765c7d41e42eb1f448a9778
3
- size 3881
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Message Action Buttons - DOM helpers for message action buttons
2
+
3
+ import { ICON_SELECTOR, setIconName } from "/js/icons.js";
4
+
5
+ const ACTION_ICON_MAP = {
6
+ detail: "open_in_full",
7
+ speak: "volume_up",
8
+ copy: "content_copy",
9
+ };
10
+
11
+ const ACTION_LABELS = {
12
+ detail: "View details",
13
+ speak: "Speak",
14
+ copy: "Copy",
15
+ };
16
+
17
+ function resolveActionIcon(icon) {
18
+ if (!icon) return "";
19
+ return ACTION_ICON_MAP[icon] || icon;
20
+ }
21
+
22
+ function buildActionLabel(icon, text) {
23
+ const baseLabel = ACTION_LABELS[icon] || text || icon;
24
+ if (text && ACTION_LABELS[icon]) return `${ACTION_LABELS[icon]} ${text}`;
25
+ return baseLabel;
26
+ }
27
+
28
+ /**
29
+ * Copy text to clipboard with fallback for non-secure contexts
30
+ */
31
+ export async function copyToClipboard(text) {
32
+ if (navigator.clipboard && window.isSecureContext) {
33
+ await navigator.clipboard.writeText(text);
34
+ } else {
35
+ // Fallback for local dev / non-secure contexts
36
+ const textarea = document.createElement("textarea");
37
+ textarea.value = text;
38
+ textarea.style.cssText = "position:fixed;left:-9999px";
39
+ document.body.appendChild(textarea);
40
+ textarea.select();
41
+ document.execCommand("copy");
42
+ document.body.removeChild(textarea);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Show visual feedback on a button (success/error state)
48
+ */
49
+ export function showButtonFeedback(button, success, originalIcon) {
50
+ const icon = button.querySelector(ICON_SELECTOR);
51
+ if (!icon) return;
52
+
53
+ setIconName(icon, success ? "check" : "error");
54
+ button.classList.add(success ? "success" : "error");
55
+
56
+ setTimeout(() => {
57
+ setIconName(icon, originalIcon);
58
+ button.classList.remove("success", "error");
59
+ }, 1000);
60
+ }
61
+
62
+ export function syncActionButtons(container, actionButtons = []) {
63
+ const previous = container.__managedActionButtons || [];
64
+ const next = actionButtons.filter(Boolean).map((button, index) => {
65
+ const existing = previous[index];
66
+ if (
67
+ existing?.isConnected &&
68
+ existing.dataset.actionKey === button.dataset.actionKey
69
+ ) {
70
+ existing.__actionHandler = button.__actionHandler;
71
+ return existing;
72
+ }
73
+ existing?.remove();
74
+ return button;
75
+ });
76
+ const anchor = [...container.children].find(
77
+ (child) =>
78
+ !previous.includes(child) && !child.classList.contains("expand-btn"),
79
+ );
80
+
81
+ previous.slice(next.length).forEach((button) => button.remove());
82
+ next.forEach((button) => container.insertBefore(button, anchor || null));
83
+ container.__managedActionButtons = next;
84
+ }
85
+
86
+ /**
87
+ * Create action button element
88
+ *
89
+ * @param {string} icon
90
+ * @param {string} [text]
91
+ * @param {(() => (any | Promise<any>)) | null} [handler]
92
+ * @returns {HTMLButtonElement}
93
+ */
94
+ export function createActionButton(icon, text = "", handler = null) {
95
+ const iconName = resolveActionIcon(icon);
96
+
97
+ const button = document.createElement("button");
98
+ button.type = "button";
99
+ button.className = `action-button action-${icon}`;
100
+ button.dataset.actionKey = `${icon}:${text}`;
101
+ button.__actionHandler = handler;
102
+ const label = buildActionLabel(icon, text);
103
+ if (label) {
104
+ button.setAttribute("aria-label", label);
105
+ button.setAttribute("title", label);
106
+ }
107
+
108
+ if (iconName) {
109
+ const iconElement = document.createElement("x-icon");
110
+ iconElement.name = iconName;
111
+ button.appendChild(iconElement);
112
+ } else if (text) {
113
+ button.textContent = text;
114
+ }
115
+
116
+ button.addEventListener("click", async (event) => {
117
+ event.stopPropagation();
118
+ if (typeof button.__actionHandler !== "function") return;
119
+ const shouldShowFeedback = Boolean(iconName); // icon === "copy" || icon === "speak";
120
+ try {
121
+ await button.__actionHandler();
122
+ if (shouldShowFeedback) {
123
+ showButtonFeedback(button, true, iconName);
124
+ }
125
+ } catch (err) {
126
+ console.error("Action button failed:", err);
127
+ if (shouldShowFeedback) {
128
+ showButtonFeedback(button, false, iconName);
129
+ }
130
+ }
131
+ });
132
+
133
+ return button;
134
+ }
webui/components/messages/process-group/process-group-dom.js CHANGED
@@ -1,3 +1,76 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:ef180e4cc6f6feb0498139015cb64ce7ae4fbf09083d3be92dc8ebb3e9a8e4f9
3
- size 2305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Process group DOM utilities (no store/state)
3
+ */
4
+ import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5
+
6
+ export async function applyModeSteps(
7
+ detailMode,
8
+ showUtils,
9
+ chatHistory = document.getElementById("chat-history"),
10
+ ) {
11
+ const mode =
12
+ detailMode ||
13
+ preferencesStore.detailMode ||
14
+ "current";
15
+
16
+ if (!chatHistory) return;
17
+
18
+ chatHistory.dataset.detailMode = mode;
19
+
20
+ const shouldExpand = mode !== "collapsed";
21
+ const allMode = mode === "expanded";
22
+ const showUtilsFlag =
23
+ typeof showUtils === "boolean"
24
+ ? showUtils
25
+ : preferencesStore.showUtils || false;
26
+ const pending = [];
27
+ const messages = Array.from(chatHistory.querySelectorAll(".process-group"));
28
+ const windowEnd = Number(chatHistory.dataset.messageWindowEnd);
29
+ const windowTotal = Number(chatHistory.dataset.messageWindowTotal);
30
+ const isAtTail =
31
+ Number.isFinite(windowEnd) &&
32
+ Number.isFinite(windowTotal) &&
33
+ windowEnd >= windowTotal;
34
+ for (let i = 0; i < messages.length; i += 1) {
35
+ const group = messages[i];
36
+ if (typeof group.__setExpanded === "function") {
37
+ pending.push(Promise.resolve(group.__setExpanded(shouldExpand)));
38
+ } else {
39
+ group.classList.toggle("expanded", shouldExpand);
40
+ }
41
+
42
+ const steps = Array.from(group.querySelectorAll(".process-step"));
43
+ const isComplete =
44
+ group.hasAttribute("data-group-complete") ||
45
+ Boolean(group.querySelector(".process-group-response"));
46
+ let currentStep = null;
47
+ if (
48
+ mode === "current" &&
49
+ isAtTail &&
50
+ group === messages[messages.length - 1] &&
51
+ !isComplete
52
+ ) {
53
+ for (let si = steps.length - 1; si >= 0; si -= 1) {
54
+ if (
55
+ showUtilsFlag ||
56
+ !steps[si].classList.contains("message-util")
57
+ ) {
58
+ currentStep = steps[si];
59
+ break;
60
+ }
61
+ }
62
+ }
63
+
64
+ for (let si = 0; si < steps.length; si += 1) {
65
+ const step = steps[si];
66
+ const shouldExpandStep = allMode || step === currentStep;
67
+ if (typeof step.__setExpanded === "function") {
68
+ pending.push(Promise.resolve(step.__setExpanded(shouldExpandStep)));
69
+ } else {
70
+ step.classList.toggle("expanded", shouldExpandStep);
71
+ }
72
+ }
73
+ }
74
+
75
+ await Promise.allSettled(pending);
76
+ }
webui/components/messages/process-group/process-group.css CHANGED
@@ -1,3 +1,914 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:41bbbb6cd4077b17682056b616b401c13223c16b38dd46fa0dd37cb729cb3011
3
- size 21217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .process-group {
2
+ display: inline-flex;
3
+ flex-direction: column;
4
+ position: relative;
5
+ z-index: 1;
6
+ margin: var(--spacing-xs) 0;
7
+ padding: var(--spacing-xs) 0;
8
+ min-width: 200px;
9
+ max-width: 100%;
10
+ box-sizing: border-box;
11
+ flex-shrink: 0;
12
+ }
13
+
14
+ .process-group.utility-only {
15
+ display: none !important;
16
+ }
17
+
18
+ .show-utility-messages .process-group.utility-only {
19
+ display: flex !important;
20
+ }
21
+
22
+ /* Embedded Process Group inside Response */
23
+ .process-group.embedded {
24
+ display: flex;
25
+ flex-direction: column;
26
+ width: 100%;
27
+ margin: 0;
28
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
29
+ /* border-bottom: 1px solid rgba(255, 255, 255, 0.08); */
30
+ background: transparent;
31
+ }
32
+
33
+ /* Message container with embedded process group */
34
+ .message-container.has-process-group {
35
+ display: inline-flex;
36
+ flex-direction: column;
37
+ padding: 0;
38
+ overflow: hidden;
39
+ min-width: 200px;
40
+ max-width: 100%;
41
+ }
42
+
43
+ .message-container.has-process-group > .message {
44
+ border: none;
45
+ background: transparent;
46
+ margin: 0;
47
+ }
48
+
49
+ /* Process Group Header */
50
+ .process-group-header {
51
+ display: flex;
52
+ align-items: center;
53
+ padding: 0;
54
+ cursor: pointer;
55
+ user-select: none;
56
+ transition: opacity 0.15s ease;
57
+ min-height: 22px;
58
+ gap: 6px;
59
+ white-space: nowrap;
60
+ }
61
+
62
+ .process-group-header:hover {
63
+ opacity: 0.85;
64
+ }
65
+
66
+ /* Expand/collapse triangle - CSS-only (no Material Icons) */
67
+ .process-group-header .expand-icon {
68
+ display: inline-block;
69
+ width: 0;
70
+ height: 0;
71
+ border-style: solid;
72
+ border-width: 4px 0 4px 6px;
73
+ border-color: transparent transparent transparent var(--color-text);
74
+ opacity: 0.5;
75
+ transition: transform 0.2s ease, opacity 0.15s ease;
76
+ flex-shrink: 0;
77
+ font-size: 0; /* Hide any text content */
78
+ margin-right: 2px;
79
+ }
80
+
81
+ .process-group-header:hover .expand-icon {
82
+ opacity: 0.8;
83
+ }
84
+
85
+ .process-group.expanded .process-group-header .expand-icon {
86
+ transform: rotate(90deg);
87
+ }
88
+
89
+ /* Group icon removed - using status badges */
90
+ .process-group-header .group-icon {
91
+ display: none;
92
+ }
93
+
94
+ /* Group title - prominent display */
95
+ .process-group-header .group-title {
96
+ flex: 0 1 auto;
97
+ font-size: var(--font-size-medium);
98
+ font-weight: 500;
99
+ color: var(--color-text-muted);
100
+ opacity: 0.9;
101
+ white-space: nowrap;
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ max-width: 350px;
105
+ }
106
+
107
+ /* Step count badge */
108
+ .process-group-header .step-count {
109
+ font-size: 0.65rem;
110
+ color: var(--color-text);
111
+ opacity: 0.5;
112
+ flex-shrink: 0;
113
+ padding: 1px 6px;
114
+ font-family: var(--font-family-code);
115
+ }
116
+
117
+ /* ===========================================
118
+ 3-Letter Status Code Badges
119
+ =========================================== */
120
+
121
+ /* Base status badge */
122
+ .step-badge {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ gap: 3px;
126
+ padding: 2px 6px;
127
+ border-radius: 3px;
128
+ font-size: 0.65rem;
129
+ font-weight: 600;
130
+ font-family: var(--font-family-code);
131
+ text-transform: uppercase;
132
+ letter-spacing: 0.5px;
133
+ flex-shrink: 0;
134
+ line-height: 1;
135
+ }
136
+
137
+ /* Status badge with icon */
138
+ .step-badge .material-symbols-outlined {
139
+ font-size: 0.8rem;
140
+ line-height: 1;
141
+ }
142
+
143
+ /* Badge icon styling */
144
+ .step-badge .badge-icon {
145
+ font-size: var(--font-size-xs);
146
+ margin-right: 2px;
147
+ opacity: 0.9;
148
+ }
149
+
150
+ /* Status colors */
151
+ /* Each status defines --step-accent for cascading to internal icons */
152
+
153
+ /* GEN - agent type (blue/cyan) */
154
+ .process-group .GEN {
155
+ --step-accent: #38bdf8;
156
+ }
157
+ .light-mode .process-group .GEN {
158
+ --step-accent: #0284c7;
159
+ }
160
+
161
+ /* END - response/done type (green) */
162
+ .process-group .END {
163
+ --step-accent: #22c55e;
164
+ }
165
+ .light-mode .process-group .END {
166
+ --step-accent: #15803d;
167
+ }
168
+
169
+ /* USE - tool usage (amber/yellow) */
170
+ .process-group .USE {
171
+ --step-accent: #fbbf24;
172
+ }
173
+ .light-mode .process-group .USE {
174
+ --step-accent: #b45309;
175
+ }
176
+
177
+ /* MCP - mcp type (amber/yellow) */
178
+ .process-group .MCP {
179
+ --step-accent: #fbbf24;
180
+ }
181
+ .light-mode .process-group .MCP {
182
+ --step-accent: #b45309;
183
+ }
184
+
185
+ /* SUB and RES - subagent type and response (teal) */
186
+ .process-group .SUB {
187
+ --step-accent: #14b8a6;
188
+ }
189
+ .light-mode .process-group .SUB {
190
+ --step-accent: #0f766e;
191
+ }
192
+ .process-group .RES {
193
+ --step-accent: #14b8a6;
194
+ }
195
+ .light-mode .process-group .RES {
196
+ --step-accent: #0f766e;
197
+ }
198
+
199
+ /* EXE - code_exe type (magenta/purple) */
200
+ .process-group .EXE {
201
+ --step-accent: #ba68c8;
202
+ }
203
+ .light-mode .process-group .EXE {
204
+ --step-accent: #7c3aed;
205
+ }
206
+
207
+ /* WWW - browser type (indigo) */
208
+ .process-group .WWW {
209
+ --step-accent: #818cf8;
210
+ }
211
+ .light-mode .process-group .WWW {
212
+ --step-accent: #4f46e5;
213
+ }
214
+
215
+ /* WAIT - progress type (slate) */
216
+ .process-group .HDL {
217
+ --step-accent: #94a3b8;
218
+ }
219
+ .light-mode .process-group .HDL {
220
+ --step-accent: #475569;
221
+ }
222
+
223
+ /* INF - info type (gray) */
224
+ .process-group .INF {
225
+ --step-accent: #94a3b8;
226
+ }
227
+ .light-mode .process-group .INF {
228
+ --step-accent: #475569;
229
+ }
230
+
231
+ /* HNT - hint type (yellow-green) */
232
+ .process-group .HNT {
233
+ --step-accent: #a3e635;
234
+ }
235
+ .light-mode .process-group .HNT {
236
+ --step-accent: #65a30d;
237
+ }
238
+
239
+ /* WRN - warning type (orange) */
240
+ .process-group .WRN {
241
+ --step-accent: #f97316;
242
+ }
243
+ .light-mode .process-group .WRN {
244
+ --step-accent: #c2410c;
245
+ }
246
+
247
+
248
+ /* ERR - error type (red) */
249
+ .process-group .ERR {
250
+ --step-accent: var(--color-error-text);
251
+ }
252
+ .light-mode .process-group .ERR {
253
+ --step-accent: var(--color-error-text);
254
+ }
255
+
256
+ /* UTL - util type (gray-blue) */
257
+ .process-group .UTL {
258
+ --step-accent: #64748b;
259
+ }
260
+ .light-mode .process-group .UTL {
261
+ --step-accent: #334155;
262
+ }
263
+
264
+
265
+ .process-group .step-badge{
266
+ color: var(--step-accent);
267
+ }
268
+
269
+ .process-group .kvps-key{
270
+ color: var(--step-accent);
271
+ }
272
+
273
+
274
+ /* Completed process group styling */
275
+ .process-group-completed {
276
+ opacity: 0.95;
277
+ }
278
+
279
+ .process-group-completed .group-status {
280
+ font-weight: 700;
281
+ }
282
+
283
+ /* Subtle completion indicator on the expand icon */
284
+ .process-group-completed .expand-icon {
285
+ color: #22c55e;
286
+ }
287
+
288
+ /* Group status badge in header */
289
+ .process-group-header .group-status {
290
+ margin-left: var(--spacing-xs);
291
+ }
292
+
293
+ /* Metrics row */
294
+ .process-group-header .group-metrics {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: var(--spacing-sm);
298
+ /* margin-left: auto; */
299
+ margin-left: 2em;
300
+ font-size: 0.65rem;
301
+ font-family: var(--font-family-code);
302
+ color: var(--color-text);
303
+ opacity: 0.8;
304
+ }
305
+
306
+ .process-group-header .group-metrics .material-symbols-outlined {
307
+ font-size: var(--font-size-xs);
308
+ opacity: 0.7;
309
+ }
310
+
311
+ .process-group-header .group-metrics span[class^="metric-"] {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ gap: 2px;
315
+ }
316
+
317
+ .process-group-header .group-metrics .metric-notifications {
318
+ font-weight: 600;
319
+ }
320
+
321
+ .process-group-header .group-metrics .metric-notifications[hidden] {
322
+ display: none;
323
+ }
324
+
325
+ .process-group-header .group-metrics .metric-notifications .material-symbols-outlined {
326
+ opacity: 0.85;
327
+ }
328
+
329
+ /* Legacy timestamp/duration (for backwards compatibility) */
330
+ .process-group-header .group-timestamp {
331
+ font-size: 0.65rem;
332
+ color: rgba(255, 255, 255, 0.65);
333
+ flex-shrink: 0;
334
+ font-family: var(--font-family-code);
335
+ display: none; /* Hidden, replaced by metrics */
336
+ }
337
+
338
+ .process-group-header .group-duration {
339
+ font-size: var(--font-size-xs);
340
+ color: #81c784;
341
+ flex-shrink: 0;
342
+ font-family: var(--font-family-code);
343
+ min-width: 40px;
344
+ text-align: right;
345
+ display: none; /* Hidden, replaced by metrics */
346
+ }
347
+
348
+ /* Process Group Content - Animated expand/collapse */
349
+ .process-group-content {
350
+ display: grid;
351
+ grid-template-rows: 0fr;
352
+ opacity: 0;
353
+ margin-top: 0;
354
+ padding-top: 0;
355
+ border-top: 1px solid transparent;
356
+ transition: grid-template-rows 0.25s ease-out,
357
+ opacity 0.2s ease-out,
358
+ margin-top 0.25s ease-out,
359
+ padding-top 0.25s ease-out,
360
+ border-color 0.2s ease-out;
361
+ overflow: hidden;
362
+ color: var(--color-text-muted);
363
+ max-width: 100%;
364
+ }
365
+
366
+ .process-group-content > .process-steps {
367
+ min-height: 0;
368
+ }
369
+
370
+ .process-group.expanded .process-group-content {
371
+ grid-template-rows: 1fr;
372
+ opacity: 1;
373
+ margin-top: var(--spacing-xs);
374
+ padding-top: var(--spacing-xs);
375
+ /* border-top-color: var(--color-border); */
376
+ }
377
+
378
+ /* Process Steps List */
379
+ .process-steps {
380
+ padding: 0;
381
+ display: flex;
382
+ flex-direction: column;
383
+ gap: 2px;
384
+ max-width: 100%;
385
+ overflow: hidden;
386
+ }
387
+
388
+ .process-group-show-more {
389
+ align-self: flex-start;
390
+ appearance: none;
391
+ display: inline-flex;
392
+ align-items: center;
393
+ border: 0;
394
+ background: transparent;
395
+ color: var(--color-text-muted);
396
+ cursor: pointer;
397
+ font-family: var(--font-family-main);
398
+ font-size: var(--font-size-xs);
399
+ padding: var(--spacing-xs) 0;
400
+ opacity: 0.7;
401
+ text-decoration: none;
402
+ transition: opacity 0.15s ease;
403
+ }
404
+
405
+ .process-group-show-more:hover,
406
+ .process-group-show-more:focus-visible {
407
+ opacity: 1;
408
+ }
409
+
410
+ /* Individual Process Step */
411
+ .process-step {
412
+ display: flex;
413
+ flex-direction: column;
414
+ padding: 0.1em;
415
+ transition: background-color 0.15s ease;
416
+ max-width: 100%;
417
+ overflow: hidden;
418
+ }
419
+
420
+ .process-step.message-util {
421
+ display: none;
422
+ }
423
+
424
+ .show-utility-messages .process-step.message-util {
425
+ display: flex;
426
+ }
427
+
428
+ /* Utility/Info/Hint steps have subtle background tint */
429
+ .process-step[data-type="util"],
430
+ .process-step[data-type="info"],
431
+ .process-step[data-type="hint"] {
432
+ background-color: rgba(120, 115, 100, 0);
433
+ }
434
+
435
+ .process-step[data-type="util"]:hover,
436
+ .process-step[data-type="info"]:hover,
437
+ .process-step[data-type="hint"]:hover {
438
+ background-color: rgba(255, 255, 255, 0.03);
439
+ }
440
+
441
+
442
+ .light-mode .process-step[data-type="util"]:hover,
443
+ .light-mode .process-step[data-type="info"]:hover,
444
+ .light-mode .process-step[data-type="hint"]:hover {
445
+ background-color: rgba(0, 0, 0, 0.04);
446
+ }
447
+
448
+ /* Step Header (clickable) */
449
+ .process-step-header {
450
+ display: flex;
451
+ align-items: center;
452
+ cursor: pointer;
453
+ user-select: none;
454
+ gap: 4px;
455
+ min-height: 18px;
456
+ max-width: 100%;
457
+ overflow: hidden;
458
+ }
459
+
460
+ /* Step title */
461
+ .process-step-header .step-title {
462
+ flex: 1;
463
+ font-size: 0.8rem;
464
+ /* color: var(--color-text-muted); */
465
+ white-space: nowrap;
466
+ overflow: hidden;
467
+ text-overflow: ellipsis;
468
+ margin-left: var(--spacing-xs);
469
+ min-width: 0;
470
+ }
471
+
472
+ /* Step expand icon - CSS triangle (no Material Icons) */
473
+ .process-step-header .step-expand-icon {
474
+ display: inline-block;
475
+ width: 0;
476
+ height: 0;
477
+ border-style: solid;
478
+ border-width: 3px 0 3px 5px;
479
+ border-color: transparent transparent transparent var(--color-text);
480
+ opacity: 0.4;
481
+ transition: transform 0.2s ease, opacity 0.15s ease;
482
+ flex-shrink: 0;
483
+ font-size: 0; /* Hide any text content */
484
+ margin-right: 2px;
485
+ }
486
+
487
+ .process-step-header:hover .step-expand-icon {
488
+ opacity: 0.7;
489
+ }
490
+
491
+ /* Use direct child selector to prevent affecting nested steps */
492
+ .process-step.expanded > .process-step-header > .step-expand-icon {
493
+ transform: rotate(90deg);
494
+ }
495
+
496
+ /* Step Detail Content - Animated expand/collapse */
497
+ .process-step-detail {
498
+ /* display: grid; */
499
+ grid-template-rows: 0fr;
500
+ opacity: 0;
501
+ margin-top: 0;
502
+ transition: grid-template-rows 0.2s ease-out, opacity 0.15s ease-out, margin-top 0.2s ease-out;
503
+ overflow: hidden;
504
+ max-width: 100%;
505
+ }
506
+
507
+ .process-step-detail-content{
508
+ white-space: pre-wrap;
509
+ }
510
+
511
+ .process-step-detail,
512
+ .process-step-detail-scroll {
513
+ scrollbar-width: thin; /* Firefox */
514
+ -ms-overflow-style: thin; /* IE/Edge */
515
+ }
516
+
517
+ /* Hide scrollbar for Chrome/Safari/Webkit */
518
+ .process-step-detail::-webkit-scrollbar,
519
+ .process-step-detail-scroll::-webkit-scrollbar {
520
+ width: thin;
521
+ height: thin;
522
+ }
523
+
524
+ .process-step:not(.expanded) > .process-step-detail {
525
+ margin-top: 0;
526
+ }
527
+
528
+ .process-step:not(.expanded) > .process-step-detail > .process-step-detail-scroll {
529
+ padding: 0;
530
+ margin-top: 0;
531
+ max-height: 0;
532
+ overflow: hidden;
533
+ }
534
+
535
+ .process-step-detail > .process-step-detail-scroll {
536
+ min-height: 0;
537
+ }
538
+
539
+ /* Use direct child selector (>) to prevent cascading to nested steps */
540
+ .process-step.expanded > .process-step-detail {
541
+ /* grid-template-rows: 1fr; */
542
+ opacity: 1;
543
+ overflow: hidden;
544
+ margin-top: var(--spacing-xs);
545
+ max-width: 100%;
546
+ padding-left: 17px;
547
+ padding-right: 1em;
548
+ }
549
+
550
+ .process-step.expanded > .process-step-detail > .process-step-detail-scroll {
551
+ max-height: 40em;
552
+ overflow: auto;
553
+ max-width: 100%;
554
+ width: fit-content;
555
+ box-sizing: content-box;
556
+ border-radius: var(--border-radius-sm);
557
+ -webkit-overflow-scrolling: touch;
558
+ scrollbar-width: thin;
559
+ -ms-overflow-style: thin;
560
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
561
+ /* overscroll-behavior: contain; */
562
+ }
563
+
564
+ .process-step.expanded > .process-step-detail > .process-step-detail-scroll > .process-step-detail-scroll {
565
+ scrollbar-width: thin;
566
+ -ms-overflow-style: thin;
567
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
568
+ }
569
+
570
+ .process-step-detail-scroll {
571
+ transition: max-height 0.2s ease-out, padding 0.2s ease-out, margin-top 0.2s ease-out;
572
+ padding: var(--spacing-xs) 0;
573
+ margin-top: var(--spacing-xxs);
574
+ /* margin-left: 28px; */
575
+ background-color: transparent;
576
+ font-size: var(--font-size-xs);
577
+ line-height: 1.5;
578
+ -webkit-overflow-scrolling: touch; /* smooth scrolling on iOS */
579
+ overscroll-behavior-x: contain;
580
+ }
581
+
582
+ /* .process-step-detail-scroll pre {
583
+ margin: 0;
584
+ white-space: pre-wrap;
585
+ word-break: break-word;
586
+ font-family: var(--font-family-code);
587
+ font-size: var(--font-size-xs);
588
+ color: var(--color-text);
589
+ opacity: 0.8;
590
+ } */
591
+
592
+ /* KVPs in step detail - CSS Grid for aligned columns */
593
+ .process-step-detail-scroll .step-kvps {
594
+ display: grid;
595
+ grid-template-columns: auto minmax(0, 1fr);
596
+ gap: var(--spacing-xs) var(--spacing-sm);
597
+ align-items: start;
598
+ }
599
+
600
+ .process-step-detail-scroll .step-kvp {
601
+ display: contents; /* Children participate in parent grid */
602
+ }
603
+
604
+ .process-step-detail-scroll .step-kvp-key {
605
+ color: var(--step-accent, var(--color-primary));
606
+ font-weight: 500;
607
+ opacity: 0.8;
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: end;
611
+ white-space: nowrap;
612
+ }
613
+
614
+ .process-step-detail-scroll .step-kvp-key .material-symbols-outlined {
615
+ font-size: 0.9rem;
616
+ color: var(--step-accent, var(--color-primary)) !important;
617
+ }
618
+
619
+ .process-step-detail-scroll .step-kvp-value {
620
+ /* color: var(--color-text); */
621
+ opacity: 0.85;
622
+ word-break: break-word;
623
+ overflow-wrap: anywhere;
624
+ white-space: pre-wrap;
625
+ font-size: 0.75rem;
626
+ line-height: 1.5;
627
+ min-width: 0;
628
+ }
629
+
630
+ .process-step-detail-scroll .step-kvp-value p {
631
+ margin: 0;
632
+ word-break: break-word;
633
+ overflow-wrap: anywhere;
634
+ }
635
+
636
+
637
+ /* Tool arguments - CSS Grid for aligned columns */
638
+ /* .process-step-detail-scroll .step-tool-args {
639
+ display: grid;
640
+ grid-template-columns: auto 1fr;
641
+ gap: 4px 8px;
642
+ margin: var(--spacing-xs) 0;
643
+ align-items: baseline;
644
+ } */
645
+
646
+ /* .process-step-detail-scroll .tool-arg-row {
647
+ display: contents;
648
+ } */
649
+
650
+ /* .process-step-detail-scroll .tool-arg-label {
651
+ font-size: 0.68rem;
652
+ font-weight: 600;
653
+ color: var(--step-accent, var(--color-primary));
654
+ display: flex;
655
+ align-items: center;
656
+ justify-content: end;
657
+ } */
658
+
659
+ /* .process-step-detail-scroll .tool-arg-label .material-symbols-outlined {
660
+ font-size: 0.9rem;
661
+ opacity: 0.85;
662
+ color: var(--step-accent, var(--color-primary));
663
+ } */
664
+
665
+ /* .process-step-detail-scroll .tool-arg-value {
666
+ font-size: var(--font-size-xs);
667
+ color: var(--color-text);
668
+ opacity: 0.85;
669
+ word-break: break-word;
670
+ font-family: var(--font-family-code);
671
+ } */
672
+
673
+ /* Light mode tool args - transparent like dark mode */
674
+ /* .light-mode .process-step-detail-scroll .step-tool-args {
675
+ background: transparent;
676
+ border-left-color: transparent;
677
+ } */
678
+
679
+ /* .process-step-detail-scroll .step-tool-header .tool-icon {
680
+ font-size: 0.85rem;
681
+ color: var(--step-accent, var(--color-warning));
682
+ opacity: 0.85;
683
+ } */
684
+
685
+ /* .process-step-detail-scroll .step-tool-header .tool-label {
686
+ font-size: 0.68rem;
687
+ font-weight: 600;
688
+ color: var(--step-accent, var(--color-warning));
689
+ opacity: 0.85;
690
+ } */
691
+
692
+ /* .process-step-detail-scroll .step-tool-header .tool-name {
693
+ font-size: 0.72rem;
694
+ color: var(--color-text);
695
+ opacity: 0.85;
696
+ } */
697
+
698
+
699
+ /* Terminal output - self-contained scrollable block */
700
+ .process-step-detail .terminal-output {
701
+ margin: var(--spacing-xs) 0 0 0;
702
+ padding: var(--spacing-xs);
703
+ background: rgba(0, 0, 0, 0.8);
704
+ min-height: 5em;
705
+ min-width: 20em;
706
+ width: max-content;
707
+ border-radius: var(--border-radius-sm);
708
+ color: #c9d1d9;
709
+ white-space: pre;
710
+ font-family: monospace;
711
+ /* max-width: fit-content; */
712
+ /* overflow: auto; */
713
+ /* -webkit-overflow-scrolling: touch; */
714
+ /* scrollbar-width: thin; */
715
+ /* scrollbar-color: rgba(255,255,255,0.2) transparent; */
716
+ /* overscroll-behavior: contain; */
717
+ }
718
+
719
+ .process-step-detail .terminal-output::-webkit-scrollbar {
720
+ width: 4px;
721
+ height: 4px;
722
+ }
723
+
724
+ .process-step-detail .terminal-output::-webkit-scrollbar-track {
725
+ background: transparent;
726
+ }
727
+
728
+ .process-step-detail .terminal-output::-webkit-scrollbar-thumb {
729
+ background: rgba(255, 255, 255, 0.2);
730
+ border-radius: 2px;
731
+ }
732
+
733
+ .process-step-detail .terminal-output::-webkit-scrollbar-thumb:hover {
734
+ background: rgba(255, 255, 255, 0.35);
735
+ }
736
+
737
+ /* Light mode terminal */
738
+ .light-mode .process-step-detail .terminal-output {
739
+ background: rgba(0, 0, 0, 0.05);
740
+ color: black;
741
+ scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
742
+ }
743
+
744
+ .light-mode .process-step-detail .terminal-output::-webkit-scrollbar-thumb {
745
+ background: rgba(0, 0, 0, 0.15);
746
+ }
747
+
748
+ .light-mode .process-step-detail .terminal-output::-webkit-scrollbar-thumb:hover {
749
+ background: rgba(0, 0, 0, 0.3);
750
+ }
751
+
752
+ /* Light mode adjustments */
753
+ .light-mode .process-group.expanded .process-group-content {
754
+ border-top-color: rgba(0, 0, 0, 0.06);
755
+ }
756
+
757
+ .light-mode .process-step-detail-scroll {
758
+ background-color: transparent;
759
+ }
760
+
761
+ /* Light mode text colors for process group */
762
+ .light-mode .process-group-header .group-title,
763
+ .light-mode .process-step-header .step-title {
764
+ color: var(--color-text-muted);
765
+ }
766
+
767
+
768
+
769
+ /* Animation for loading state */
770
+ @keyframes pulse-step {
771
+ 0%, 100% { opacity: 0.5; }
772
+ 50% { opacity: 0.8; }
773
+ }
774
+
775
+ .step-title.shiny-text { color: transparent !important; -webkit-background-clip: text; background-clip: text; animation: shine 1s linear infinite; }
776
+ @keyframes shine { to { background-position: -100% center; } }
777
+
778
+ /* Responsive adjustments */
779
+ @media (max-width: 768px) {
780
+ .process-group {
781
+ padding: var(--spacing-xs) var(--spacing-sm);
782
+ }
783
+
784
+ .process-step-header .step-title {
785
+ font-size: var(--font-size-xs);
786
+ }
787
+
788
+ .process-step-detail-scroll {
789
+ margin-left: 0;
790
+ }
791
+ }
792
+
793
+ /* ===========================================
794
+ Preferences Visibility Controls
795
+ These rules work with preferences-store toggles
796
+ =========================================== */
797
+
798
+ /* .process-step.message-util {
799
+ display: none;
800
+ }
801
+
802
+ .step-kvp.msg-thoughts {
803
+ display: none;
804
+ }
805
+
806
+ .process-step-detail-scroll pre.msg-json {
807
+ display: none;
808
+ } */
809
+
810
+ /* Nested Process Steps (Subordinate Agents) */
811
+
812
+ .process-nested-container {
813
+ display: grid;
814
+ grid-template-rows: 0fr;
815
+ opacity: 0;
816
+ overflow: hidden;
817
+ transition: grid-template-rows 0.25s ease-out, opacity 0.2s ease-out;
818
+ gap: 2px;
819
+ max-width: 100%;
820
+ }
821
+
822
+ /* Only show nested container when parent step is expanded */
823
+ .process-step.expanded > .process-nested-container {
824
+ grid-template-rows: 1fr;
825
+ opacity: 1;
826
+ margin-top: 2px;
827
+ /* Keep overflow hidden to allow nested steps to control their own expansion */
828
+ }
829
+
830
+ /* Inner wrapper to handle grid transition content */
831
+ .process-nested-inner {
832
+ min-height: 0;
833
+ display: flex;
834
+ flex-direction: column;
835
+ gap: 2px;
836
+ max-width: 100%;
837
+ overflow: hidden;
838
+ }
839
+
840
+ /* All nested containers have same indentation (no cascading) */
841
+ .nested-step .process-nested-container {
842
+ margin-left: 0;
843
+ }
844
+
845
+ /* Response content in process steps */
846
+ .process-step-detail-scroll .step-response-content {
847
+ font-size: 0.75rem;
848
+ line-height: 1.6;
849
+ color: var(--color-text);
850
+ opacity: 0.9;
851
+ margin: var(--spacing-xs) 0;
852
+ word-break: break-word;
853
+ overflow-wrap: anywhere;
854
+ }
855
+
856
+ .process-step-detail-scroll .step-response-content p {
857
+ margin: 0.5em 0;
858
+ }
859
+
860
+ .process-step-detail-scroll .step-response-content ul,
861
+ .process-step-detail-scroll .step-response-content ol {
862
+ margin: 0.5em 0;
863
+ padding-left: 1.5em;
864
+ }
865
+
866
+ /* Warning/error content in process steps */
867
+ .process-step-detail-scroll .step-warning-content {
868
+ font-size: 0.72rem;
869
+ line-height: 1.5;
870
+ color: var(--color-warning);
871
+ opacity: 0.9;
872
+ margin: var(--spacing-xs) 0;
873
+ padding: var(--spacing-xs);
874
+ border-left: 2px solid var(--color-warning);
875
+ border-radius: 3px;
876
+ }
877
+
878
+ /* Browser screenshot content in process steps */
879
+ .process-step-detail-scroll .screenshot-img {
880
+ max-width: 100%;
881
+ max-height: 400px;
882
+ border-radius: 4px;
883
+ border: 1px solid rgba(255, 255, 255, 0.1);
884
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
885
+ transition: box-shadow 0.2s ease, filter 0.2s ease;
886
+ object-fit: contain;
887
+ }
888
+
889
+ .process-step-detail-scroll .screenshot-img:hover {
890
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
891
+ filter: brightness(1.02);
892
+ }
893
+
894
+ /* Light mode screenshot border */
895
+ .light-mode .process-step-detail-scroll .screenshot-img {
896
+ border: 1px solid rgba(0, 0, 0, 0.15);
897
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
898
+ }
899
+
900
+ .light-mode .process-step-detail-scroll .screenshot-img:hover {
901
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
902
+ }
903
+
904
+ /* View Details button in step detail */
905
+ .step-detail-actions {
906
+ display: flex;
907
+ padding-left: 18px;
908
+ min-height: 0; /* Required for grid collapse animation */
909
+ }
910
+
911
+ /* Hide View Details button when step is collapsed */
912
+ .process-step:not(.expanded) > .process-step-detail > .step-detail-actions {
913
+ display: none;
914
+ }
webui/components/messages/resize/message-resize-store.js CHANGED
@@ -1,3 +1,137 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3bc738ec8ebc2bc63e2dd68ede24dac25575f5f088a6e7e0a0f5bce139ac237d
3
- size 4271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { toggleCssProperty } from "/js/css.js";
3
+
4
+ const model = {
5
+ settings: {},
6
+
7
+ async init() {
8
+ this.settings =
9
+ JSON.parse(localStorage.getItem("messageResizeSettings") || "null") ||
10
+ this._getDefaultSettings();
11
+ this._applyAllSettings();
12
+ },
13
+
14
+ _getDefaultSettings() {
15
+ return {
16
+ "message": { minimized: false, maximized: false },
17
+ "message-agent": { minimized: true, maximized: false },
18
+ "message-agent-response": { minimized: false, maximized: true },
19
+ };
20
+ },
21
+
22
+ getSetting(className) {
23
+ return this.settings[className] || { minimized: false, maximized: false };
24
+ },
25
+
26
+ _getDefaultSetting() {
27
+ return { minimized: false, maximized: false };
28
+ },
29
+
30
+ _setSetting(className, setting) {
31
+ this.settings[className] = setting;
32
+ localStorage.setItem(
33
+ "messageResizeSettings",
34
+ JSON.stringify(this.settings)
35
+ );
36
+ },
37
+
38
+ _applyAllSettings() {
39
+ for (const [className, setting] of Object.entries(this.settings)) {
40
+ this._applySetting(className, setting);
41
+ }
42
+ },
43
+
44
+ async minimizeMessageClass(className, event) {
45
+ const set = this.getSetting(className);
46
+ set.minimized = !set.minimized;
47
+ this._setSetting(className, set);
48
+ this._applySetting(className, set);
49
+ this._applyScroll(event);
50
+ },
51
+
52
+ async maximizeMessageClass(className, event) {
53
+ const set = this.getSetting(className);
54
+ if (set.minimized) return this.minimizeMessageClass(className, event); // if minimized, unminimize first
55
+ set.maximized = !set.maximized;
56
+ this._setSetting(className, set);
57
+ this._applySetting(className, set);
58
+ this._applyScroll(event);
59
+ },
60
+
61
+ _applyScroll(event) {
62
+ if (!event || !event.target) {
63
+ return;
64
+ }
65
+
66
+ // Store the element reference to avoid issues with event being modified
67
+ const targetElement = event.target;
68
+ const clickY = event.clientY;
69
+
70
+ // Use requestAnimationFrame for smoother timing with browser rendering
71
+ // requestAnimationFrame(() => {
72
+ try {
73
+ // Get fresh measurements after potential re-renders
74
+ const rect = targetElement.getBoundingClientRect();
75
+ const viewHeight = window.innerHeight || document.documentElement.clientHeight;
76
+
77
+ // Get chat history element
78
+ const chatHistory = document.getElementById('chat-history');
79
+ if (!chatHistory) {
80
+ return;
81
+ }
82
+
83
+ // Get chat history position
84
+ const chatRect = chatHistory.getBoundingClientRect();
85
+
86
+ // Calculate element's middle position relative to chat history
87
+ const elementHeight = rect.height;
88
+ const elementMiddle = rect.top + (elementHeight / 2);
89
+ const relativeMiddle = elementMiddle - chatRect.top;
90
+
91
+ // Calculate target scroll position
92
+ let scrollTop;
93
+
94
+ if (typeof clickY === 'number') {
95
+ // Calculate based on click position
96
+ const clickRelativeToChat = clickY - chatRect.top;
97
+ // Add current scroll position and adjust to keep element middle at click position
98
+ scrollTop = chatHistory.scrollTop + relativeMiddle - clickRelativeToChat;
99
+ } else {
100
+ // Position element middle at 50% from the top of chat history viewport (center)
101
+ const targetPosition = chatHistory.clientHeight * 0.5;
102
+ scrollTop = chatHistory.scrollTop + relativeMiddle - targetPosition;
103
+ }
104
+
105
+ // Apply scroll with instant behavior
106
+ chatHistory.scrollTo({
107
+ top: scrollTop,
108
+ behavior: "auto"
109
+ });
110
+ } catch (e) {
111
+ // Silent error handling
112
+ }
113
+ // });
114
+ },
115
+
116
+ _applySetting(className, setting) {
117
+ toggleCssProperty(
118
+ `.${className} .message-body`,
119
+ "max-height",
120
+ setting.maximized ? "unset" : "30em"
121
+ );
122
+ toggleCssProperty(
123
+ `.${className} .message-body`,
124
+ "overflow-y",
125
+ setting.maximized ? "hidden" : "auto"
126
+ );
127
+ toggleCssProperty(
128
+ `.${className} .message-body`,
129
+ "display",
130
+ setting.minimized ? "none" : "block"
131
+ );
132
+ },
133
+ };
134
+
135
+ const store = createStore("messageResize", model);
136
+
137
+ export { store };
webui/components/modals/AGENTS.md CHANGED
@@ -1,3 +1,34 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7bf42ea967b7a8890f9db0e8cd5c4c144f231b7ab8c4b890c03b229fffb42b23
3
- size 1086
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal Components DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own WebUI modal component content loaded through the shared modal stack.
6
+
7
+ ## Ownership
8
+
9
+ - Each direct child folder owns one modal workflow and its store.
10
+ - Modal HTML files own body content, titles, scoped styles, and `data-modal-footer` content.
11
+ - Modal store files own modal-local state and cleanup.
12
+
13
+ ## Local Contracts
14
+
15
+ - Use `openModal(path)` and `closeModal()` from `/js/modals.js`.
16
+ - Keep footer content marked with `data-modal-footer` when using pinned modal actions.
17
+ - Do not introduce a parallel overlay, backdrop, or teleport modal system.
18
+
19
+ ## Work Guidance
20
+
21
+ - Keep modal state cleanup explicit because stores may outlive DOM nodes.
22
+ - Preserve stacked modal, Escape, click-outside, scroll, and footer behavior.
23
+
24
+ ## Verification
25
+
26
+ - Smoke-test open, close, Escape, click-outside, scrolling, stacked modals, and pinned footers after modal changes.
27
+
28
+ ## Child DOX Index
29
+
30
+ Direct child DOX files:
31
+
32
+ | Child | Scope |
33
+ | --- | --- |
34
+ | [file-browser/AGENTS.md](file-browser/AGENTS.md) | File browser modal and right-canvas Files surface workflow. |
webui/components/modals/context/context-store.js CHANGED
@@ -1,3 +1,132 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:03662f99e944177b6d5227f6a7c2810bd8803aad119e8891690bfafe2ef1e495
3
- size 3809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+
3
+ const model = {
4
+ // State
5
+ isLoading: false,
6
+ contextData: null,
7
+ tokenCount: 0,
8
+ error: null,
9
+ editor: null,
10
+ closePromise: null,
11
+
12
+ // Open Context Window modal
13
+ async open() {
14
+ if (this.isLoading) return; // Prevent double-open
15
+
16
+ this.isLoading = true;
17
+ this.error = null;
18
+ this.contextData = null;
19
+ this.tokenCount = 0;
20
+
21
+ try {
22
+ // Open modal FIRST (immediate UI feedback, but DON'T await)
23
+ this.closePromise = window.openModal('modals/context/context.html');
24
+
25
+ // Setup cleanup on modal close
26
+ if (this.closePromise && typeof this.closePromise.then === 'function') {
27
+ this.closePromise.then(() => {
28
+ this.destroy();
29
+ });
30
+ }
31
+
32
+ this.updateModalTitle(); // Set initial "loading" title
33
+
34
+ // Fetch data from backend
35
+ const contextId = window.getContext();
36
+ const response = await window.sendJsonData('/ctx_window_get', {
37
+ context: contextId,
38
+ });
39
+
40
+ // Update state with data
41
+ this.contextData = response.content;
42
+ this.tokenCount = response.tokens || 0;
43
+ this.isLoading = false;
44
+ this.updateModalTitle(); // Update with token count
45
+
46
+ // Initialize ACE editor
47
+ this.scheduleEditorInit();
48
+
49
+ } catch (error) {
50
+ console.error("Context fetch error:", error);
51
+ this.error = error?.message || "Failed to load context window";
52
+ this.isLoading = false;
53
+ this.updateModalTitle(); // Show error in title
54
+ }
55
+ },
56
+
57
+ scheduleEditorInit() {
58
+ // Use double requestAnimationFrame to ensure DOM is ready
59
+ window.requestAnimationFrame(() => {
60
+ if (this.isLoading || this.error) return;
61
+ window.requestAnimationFrame(() => this.initEditor());
62
+ });
63
+ },
64
+
65
+ initEditor() {
66
+ const container = document.getElementById("context-viewer-container");
67
+ if (!container) {
68
+ console.warn("Context container not found, deferring editor init");
69
+ return;
70
+ }
71
+
72
+ // Destroy old instance if exists
73
+ if (this.editor?.destroy) {
74
+ this.editor.destroy();
75
+ }
76
+
77
+ // Check if ACE is available
78
+ if (!window.ace?.edit) {
79
+ console.error("ACE editor not available");
80
+ this.error = "Editor library not loaded";
81
+ return;
82
+ }
83
+
84
+ const editorInstance = window.ace.edit("context-viewer-container");
85
+ if (!editorInstance) {
86
+ console.error("Failed to create ACE editor instance");
87
+ return;
88
+ }
89
+
90
+ this.editor = editorInstance;
91
+
92
+ // Configure theme based on dark mode (legacy parity: != "false")
93
+ const darkMode = window.localStorage?.getItem("darkMode");
94
+ const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
95
+
96
+ this.editor.setTheme(theme);
97
+ this.editor.session.setMode("ace/mode/markdown");
98
+ this.editor.setValue(this.contextData, -1); // -1 moves cursor to start
99
+ this.editor.setReadOnly(true);
100
+ this.editor.clearSelection();
101
+ },
102
+
103
+ updateModalTitle() {
104
+ window.requestAnimationFrame(() => {
105
+ const modalTitles = document.querySelectorAll(".modal.show .modal-title");
106
+ if (!modalTitles.length) return;
107
+
108
+ // Get the last (topmost) modal title
109
+ const title = modalTitles[modalTitles.length - 1];
110
+ if (!title) return;
111
+
112
+ if (this.error) {
113
+ title.textContent = "Context Window – Error";
114
+ } else if (this.isLoading) {
115
+ title.textContent = "Context Window (loading…)";
116
+ } else {
117
+ title.textContent = `Context Window ~${this.tokenCount} tokens`;
118
+ }
119
+ });
120
+ },
121
+
122
+ // Optional: cleanup method for lifecycle management
123
+ destroy() {
124
+ if (this.editor?.destroy) {
125
+ this.editor.destroy();
126
+ }
127
+ this.editor = null;
128
+ },
129
+ };
130
+
131
+ export const store = createStore("context", model);
132
+
webui/components/modals/context/context.html CHANGED
@@ -1,3 +1,103 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:86d5291d689d895b09e8c8a278debbd59423433eeff41e8cea2564056216ae64
3
- size 2317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <title>Context Window</title>
4
+ <script type="module">
5
+ import { store } from "/components/modals/context/context-store.js";
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <div x-data>
10
+ <template x-if="$store.context">
11
+ <div class="context-modal-root">
12
+
13
+ <!-- Loading State -->
14
+ <template x-if="$store.context.isLoading">
15
+ <div class="loading-state">
16
+ <div class="loading-spinner"></div>
17
+ <p>Loading context window…</p>
18
+ </div>
19
+ </template>
20
+
21
+ <!-- Error State -->
22
+ <template x-if="$store.context.error">
23
+ <div class="error-state">
24
+ <p class="error-message" x-text="$store.context.error"></p>
25
+ </div>
26
+ </template>
27
+
28
+ <!-- Content (ACE Editor) -->
29
+ <template x-if="!$store.context.isLoading && !$store.context.error">
30
+ <div id="context-viewer-container" class="no-scrollbar"></div>
31
+ </template>
32
+
33
+ </div>
34
+ </template>
35
+ </div>
36
+
37
+ <style>
38
+ .context-modal-root {
39
+ display: flex;
40
+ flex-direction: column;
41
+ width: 100%;
42
+ height: 100%;
43
+ min-height: 400px;
44
+ }
45
+
46
+ .loading-state,
47
+ .error-state {
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ justify-content: center;
52
+ padding: var(--spacing-lg);
53
+ min-height: 200px;
54
+ }
55
+
56
+ .loading-spinner {
57
+ width: 40px;
58
+ height: 40px;
59
+ border: 3px solid var(--color-border);
60
+ border-top: 3px solid var(--color-primary);
61
+ border-radius: 50%;
62
+ animation: spin 1s linear infinite;
63
+ margin-bottom: var(--spacing-md);
64
+ }
65
+
66
+ @keyframes spin {
67
+ 0% { transform: rotate(0deg); }
68
+ 100% { transform: rotate(360deg); }
69
+ }
70
+
71
+ .loading-state p,
72
+ .error-state p {
73
+ color: var(--color-text-secondary);
74
+ margin: 0;
75
+ }
76
+
77
+ .error-message {
78
+ color: var(--color-error);
79
+ font-weight: 500;
80
+ }
81
+
82
+ /* ACE Editor Container */
83
+ #context-viewer-container {
84
+ width: 100%;
85
+ height: 71vh;
86
+ border-radius: 0.4rem;
87
+ overflow: auto;
88
+ }
89
+
90
+ /* ACE Editor Scrollbar */
91
+ .ace_scrollbar-v {
92
+ overflow-y: auto;
93
+ }
94
+
95
+ /* Viewer Styles */
96
+ .context-modal-root {
97
+ overflow: hidden;
98
+ }
99
+ </style>
100
+
101
+ </body>
102
+ </html>
103
+
webui/components/modals/file-browser/AGENTS.md CHANGED
@@ -1,3 +1,42 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7a0f3f9e5662631ccdb1d7a88464fe108ac5b739c9dd7de73acacca4c3ed7435
3
- size 2937
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # File Browser Modal DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the WebUI file browser workflow for modal and right-canvas Files surface entry points.
6
+
7
+ ## Ownership
8
+
9
+ - `file-browser.html` owns file list markup, path/search controls, scoped styles, and modal/canvas footer behavior.
10
+ - `file-browser-store.js` owns directory loading, remembered-location state, selection, upload/download/delete actions, and surface handoff state.
11
+ - `rename-modal.html` owns rename and create-folder prompts that reuse the file-browser store.
12
+
13
+ ## Local Contracts
14
+
15
+ - Keep `open(path)` as the modal entry point for workflows that await browser close.
16
+ - Keep `openSurface(path)` as the right-canvas entry point; it must load files without opening or awaiting a modal.
17
+ - The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode.
18
+ - Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`.
19
+ - Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
20
+ - Preserve picker modes for Editor Open and Save As: Editor Open selects one or more Markdown or plain text files with a pinned primary action, and Save As selects the current folder plus a `.md` or `.txt` file name.
21
+ - Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
22
+ - Keep Extract available for supported archive files; extraction must create a new sibling folder and reject unsafe member paths and links.
23
+ - Keep row action menus visible without disabling file-list scrolling; menus may float outside the scroll container but must still close on outside click, Escape, action click, and list scroll.
24
+ - Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
25
+ - Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
26
+ - Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.
27
+ - Preserve surface actions that route supported files to Browser, Desktop, or Editor.
28
+ - Keep native drag moves available outside picker modes: dragging an unselected row moves only that row without changing selection, dragging a selected row moves the selection, folder rows accept drops, and Up moves items to the parent directory. Moves must reject overwrites and self-nesting.
29
+
30
+ ## Work Guidance
31
+
32
+ - Share markup and store behavior between modal and canvas modes; branch only on explicit component `mode`.
33
+ - Keep modal footer relocation compatible with `data-modal-footer` while allowing canvas mode to render inline controls.
34
+
35
+ ## Verification
36
+
37
+ - Smoke-test opening Files as a modal and from the right-canvas rail.
38
+ - Run targeted file-browser tests after behavior changes.
39
+
40
+ ## Child DOX Index
41
+
42
+ No child DOX files.
webui/components/modals/file-browser/file-browser-store.js CHANGED
@@ -1,3 +1,1599 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:5ce9a395bf81628c721f15f051adf25f21a101e7a0e65fd1472c629da22391fb
3
- size 51221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { callJsonApi, fetchApi } from "/js/api.js";
3
+ import { formatDateTime } from "/js/time-utils.js";
4
+ import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
5
+ import {
6
+ openLatest as openLatestSurface,
7
+ setupFloatingSurfaceModalChrome,
8
+ } from "/js/surfaces.js";
9
+
10
+ const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
11
+ const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
12
+ const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
13
+ const PICKER_MODE_NONE = "";
14
+ const PICKER_MODE_TEXT_OPEN = "text-open";
15
+ const PICKER_MODE_SAVE_AS = "save-as";
16
+ const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]);
17
+ const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
18
+ const BROWSER_EXTENSIONS = new Set([
19
+ "html",
20
+ "htm",
21
+ "xhtml",
22
+ "svg",
23
+ "xml",
24
+ "pdf",
25
+ "png",
26
+ "jpg",
27
+ "jpeg",
28
+ "gif",
29
+ "webp",
30
+ "bmp",
31
+ "ico",
32
+ ]);
33
+ const ARCHIVE_SUFFIXES = [".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar", ".tgz", ".tbz", ".tbz2", ".txz", ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz", ".zst"];
34
+
35
+ const SURFACE_ACTIONS = {
36
+ editor: {
37
+ label: "Open in Editor",
38
+ icon: "article",
39
+ title: "Open text in Editor",
40
+ },
41
+ desktop: {
42
+ label: "Open in Desktop",
43
+ icon: "desktop_windows",
44
+ title: "Open document in Desktop",
45
+ },
46
+ browser: {
47
+ label: "Open in Browser",
48
+ icon: "language",
49
+ title: "Open web-viewable file in Browser",
50
+ },
51
+ };
52
+
53
+ function delay(ms) {
54
+ return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
55
+ }
56
+
57
+ // Model migrated from legacy file_browser.js (lift-and-shift)
58
+ const model = {
59
+ // Reactive state
60
+ isLoading: false,
61
+ browser: {
62
+ title: "File Browser",
63
+ currentPath: "",
64
+ entries: [],
65
+ parentPath: "",
66
+ sortBy: "name",
67
+ sortDirection: "asc",
68
+ },
69
+ history: [], // navigation stack
70
+ initialPath: "", // Store path for open() call
71
+ closePromise: null,
72
+ isSurfaceHandoff: false,
73
+ surfaceHandoffPath: "",
74
+ error: null,
75
+ pathInput: "",
76
+ pathError: "",
77
+ isPathSubmitting: false,
78
+ rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
79
+ settingsLoadPromise: null,
80
+ settingsUpdatedHandler: null,
81
+ _floatingCleanup: null,
82
+ _mountedDefaultLoadTimer: null,
83
+ renameTarget: null,
84
+ renameName: "",
85
+ renameMode: "rename",
86
+ isRenaming: false,
87
+ renameError: null,
88
+ renameAfterConfirm: null,
89
+ renamePerformAction: null,
90
+ renameValidateName: null,
91
+ openDropdownPath: null, // Track which dropdown is currently open
92
+ dropdownStyle: {},
93
+ searchQuery: "",
94
+ isBulkBusy: false,
95
+ draggedPaths: [],
96
+ dragOverPath: "",
97
+ pickerMode: PICKER_MODE_NONE,
98
+ pickerConfirmLabel: "",
99
+ pickerFilename: "",
100
+ pickerDefaultExtension: "md",
101
+ pickerFilenameError: "",
102
+ pickerOnConfirm: null,
103
+
104
+ // --- Lifecycle -----------------------------------------------------------
105
+ init() {
106
+ if (this.settingsUpdatedHandler) return;
107
+ this.settingsUpdatedHandler = (event) => {
108
+ const value = event?.detail?.file_browser_remember_last_directory;
109
+ if (typeof value !== "boolean") return;
110
+ this.rememberLastDirectory = value;
111
+ if (!value) this.clearRememberedDirectory();
112
+ };
113
+ document.addEventListener("settings-updated", this.settingsUpdatedHandler);
114
+ },
115
+
116
+ onMount(element = null, options = {}) {
117
+ this._floatingCleanup?.();
118
+ this._floatingCleanup = null;
119
+ const mode = options?.mode === "canvas" ? "canvas" : "modal";
120
+ if (mode === "modal") {
121
+ this.setupFloatingModal(element);
122
+ } else {
123
+ this.scheduleMountedDefaultLoad();
124
+ }
125
+ },
126
+
127
+ onUnmount() {
128
+ this._floatingCleanup?.();
129
+ this._floatingCleanup = null;
130
+ this.cancelMountedDefaultLoad();
131
+ },
132
+
133
+ // --- Public API (called from button/link) --------------------------------
134
+ async open(path = "", options = {}) {
135
+ if (this.isLoading) return; // Prevent double-open
136
+ this.resetOpenState(options);
137
+
138
+ try {
139
+ // Open modal FIRST (immediate UI feedback)
140
+ this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH);
141
+ await this.loadOpeningPath(path);
142
+
143
+ // await modal close
144
+ await this.closePromise;
145
+ if (!this.isSurfaceHandoff) this.destroy();
146
+
147
+ } catch (error) {
148
+ console.error("File browser error:", error);
149
+ this.error = error?.message || "Failed to load files";
150
+ this.isLoading = false;
151
+ }
152
+ },
153
+
154
+ async openSurface(path = "") {
155
+ if (this.isLoading) return false;
156
+ this.resetOpenState();
157
+
158
+ try {
159
+ const retainedPath = this.normalizeOpeningPath(
160
+ path
161
+ || this.surfaceHandoffPath
162
+ || this.browser.currentPath
163
+ || this.initialPath
164
+ );
165
+ return await this.loadOpeningPath(retainedPath);
166
+ } catch (error) {
167
+ console.error("File browser surface error:", error);
168
+ this.error = error?.message || "Failed to load files";
169
+ this.isLoading = false;
170
+ return false;
171
+ }
172
+ },
173
+
174
+ handleClose() {
175
+ // Close the modal manually
176
+ this.disposeScopedTooltips();
177
+ window.closeModal(FILE_BROWSER_MODAL_PATH);
178
+ },
179
+
180
+ async openTextPicker(path = "", onConfirm = null) {
181
+ return await this.open(path, {
182
+ pickerMode: PICKER_MODE_TEXT_OPEN,
183
+ confirmLabel: "Open Selected",
184
+ onConfirm,
185
+ });
186
+ },
187
+
188
+ async openSaveAsPicker(path = "", options = {}) {
189
+ return await this.open(path, {
190
+ pickerMode: PICKER_MODE_SAVE_AS,
191
+ confirmLabel: "Save Here",
192
+ filename: options.filename || "Untitled.md",
193
+ defaultExtension: options.defaultExtension || "",
194
+ onConfirm: options.onConfirm,
195
+ });
196
+ },
197
+
198
+ destroy() {
199
+ this._floatingCleanup?.();
200
+ this._floatingCleanup = null;
201
+ this.cancelMountedDefaultLoad();
202
+ // Reset state when modal closes
203
+ this.isLoading = false;
204
+ this.history = [];
205
+ this.initialPath = "";
206
+ this.closePromise = null;
207
+ this.isSurfaceHandoff = false;
208
+ this.surfaceHandoffPath = "";
209
+ this.browser.currentPath = "";
210
+ this.browser.parentPath = "";
211
+ this.browser.entries = [];
212
+ this.openDropdownPath = null;
213
+ this.searchQuery = "";
214
+ this.isBulkBusy = false;
215
+ this.clearDragState();
216
+ this.pathInput = "";
217
+ this.pathError = "";
218
+ this.isPathSubmitting = false;
219
+ this.resetPickerState();
220
+ this.resetRenameState();
221
+ },
222
+
223
+ setupFloatingModal(element = null) {
224
+ this._floatingCleanup?.();
225
+ this._floatingCleanup = setupFloatingSurfaceModalChrome({
226
+ root: element,
227
+ modalClass: "file-browser-modal",
228
+ focusButtonClass: "file-browser-modal-focus-button",
229
+ minWidth: 420,
230
+ minHeight: 360,
231
+ });
232
+ },
233
+
234
+ cancelMountedDefaultLoad() {
235
+ if (!this._mountedDefaultLoadTimer) return;
236
+ globalThis.clearTimeout(this._mountedDefaultLoadTimer);
237
+ this._mountedDefaultLoadTimer = null;
238
+ },
239
+
240
+ scheduleMountedDefaultLoad() {
241
+ this.cancelMountedDefaultLoad();
242
+ this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => {
243
+ this._mountedDefaultLoadTimer = null;
244
+ if (this.isLoading) return;
245
+ const targetPath = this.browser.currentPath || "";
246
+ if (targetPath && this.browser.entries.length) {
247
+ this.syncPathInput();
248
+ return;
249
+ }
250
+ try {
251
+ await this.loadOpeningPath(targetPath);
252
+ } catch (error) {
253
+ console.error("File browser default path load failed:", error);
254
+ }
255
+ }, 120);
256
+ },
257
+
258
+ // --- Helpers -------------------------------------------------------------
259
+ resetOpenState(options = {}) {
260
+ this.cancelMountedDefaultLoad();
261
+ this.isLoading = true;
262
+ this.error = null;
263
+ this.history = [];
264
+ this.searchQuery = "";
265
+ this.isBulkBusy = false;
266
+ this.clearDragState();
267
+ this.pathError = "";
268
+ this.isPathSubmitting = false;
269
+ this.configurePicker(options);
270
+ },
271
+
272
+ configurePicker(options = {}) {
273
+ const mode = String(options?.pickerMode || PICKER_MODE_NONE).trim();
274
+ this.pickerMode = [PICKER_MODE_TEXT_OPEN, PICKER_MODE_SAVE_AS].includes(mode)
275
+ ? mode
276
+ : PICKER_MODE_NONE;
277
+ this.pickerConfirmLabel = String(options?.confirmLabel || "").trim()
278
+ || (this.pickerMode === PICKER_MODE_SAVE_AS ? "Save Here" : "Open Selected");
279
+ this.pickerFilename = String(options?.filename || "").trim();
280
+ this.pickerDefaultExtension = this.normalizedEditorTextExtension(
281
+ options?.defaultExtension || this.fileExtension({ name: this.pickerFilename }) || "md",
282
+ );
283
+ this.pickerFilenameError = "";
284
+ this.pickerOnConfirm = typeof options?.onConfirm === "function" ? options.onConfirm : null;
285
+ if (this.pickerMode) this.clearSelection();
286
+ },
287
+
288
+ resetPickerState() {
289
+ this.pickerMode = PICKER_MODE_NONE;
290
+ this.pickerConfirmLabel = "";
291
+ this.pickerFilename = "";
292
+ this.pickerDefaultExtension = "md";
293
+ this.pickerFilenameError = "";
294
+ this.pickerOnConfirm = null;
295
+ },
296
+
297
+ async loadOpeningPath(path = "") {
298
+ await this.loadDirectoryPreference();
299
+ const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
300
+ const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
301
+ const targetPath = explicitPath || rememberedPath || "$WORK_DIR";
302
+ this.browser.currentPath = targetPath;
303
+ this.syncPathInput();
304
+
305
+ const loaded = await this.fetchFiles(this.browser.currentPath, {
306
+ preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath),
307
+ suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath),
308
+ });
309
+ if (!loaded && rememberedPath && targetPath === rememberedPath) {
310
+ this.clearRememberedDirectory();
311
+ return await this.fetchFiles("$WORK_DIR");
312
+ }
313
+ return loaded;
314
+ },
315
+
316
+ beginSurfaceHandoff() {
317
+ this.isSurfaceHandoff = true;
318
+ this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || "";
319
+ },
320
+
321
+ finishSurfaceHandoff() {
322
+ this.isSurfaceHandoff = false;
323
+ this.surfaceHandoffPath = "";
324
+ },
325
+
326
+ cancelSurfaceHandoff() {
327
+ this.isSurfaceHandoff = false;
328
+ this.surfaceHandoffPath = "";
329
+ },
330
+
331
+ isArchive(filename) {
332
+ return ARCHIVE_SUFFIXES.some((suffix) => String(filename || "").toLowerCase().endsWith(suffix));
333
+ },
334
+
335
+ saveScrollPosition() {
336
+ // Find the file browser modal's scrollable container
337
+ // We look for the modal containing .file-browser-root to target the correct modal
338
+ const fileBrowserRoot = document.querySelector('.file-browser-root');
339
+ if (fileBrowserRoot) {
340
+ const modalScroll = fileBrowserRoot.closest('.modal-scroll');
341
+ if (modalScroll) {
342
+ return {
343
+ scrollTop: modalScroll.scrollTop,
344
+ scrollLeft: modalScroll.scrollLeft
345
+ };
346
+ }
347
+ }
348
+ return null;
349
+ },
350
+
351
+ restoreScrollPosition(scrollPos) {
352
+ if (!scrollPos) return;
353
+
354
+ const restore = () => {
355
+ const fileBrowserRoot = document.querySelector('.file-browser-root');
356
+ if (fileBrowserRoot) {
357
+ const modalScroll = fileBrowserRoot.closest('.modal-scroll');
358
+ if (modalScroll) {
359
+ modalScroll.scrollTop = scrollPos.scrollTop;
360
+ modalScroll.scrollLeft = scrollPos.scrollLeft;
361
+ }
362
+ }
363
+ };
364
+
365
+ requestAnimationFrame(() => requestAnimationFrame(restore));
366
+ },
367
+
368
+ formatFileSize(size) {
369
+ if (size === 0) return "0 Bytes";
370
+ const k = 1024;
371
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
372
+ const i = Math.floor(Math.log(size) / Math.log(k));
373
+ return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
374
+ },
375
+
376
+ formatDate(dateString) {
377
+ return formatDateTime(dateString, "short");
378
+ },
379
+
380
+ decorateEntries(entries = [], selectedPaths = new Set()) {
381
+ return entries.map((entry) => ({
382
+ ...entry,
383
+ selected: selectedPaths.has(entry.path),
384
+ }));
385
+ },
386
+
387
+ get filteredEntries() {
388
+ const query = this.searchQuery.trim().toLowerCase();
389
+ return this.browser.entries.filter((file) => {
390
+ if (!this.pickerAllowsEntry(file)) return false;
391
+ if (!query) return true;
392
+ const searchable = [
393
+ file.name,
394
+ file.path,
395
+ file.type,
396
+ file.symlink_target,
397
+ file.is_dir ? "folder directory" : "file",
398
+ ]
399
+ .filter(Boolean)
400
+ .join(" ")
401
+ .toLowerCase();
402
+ return searchable.includes(query);
403
+ });
404
+ },
405
+
406
+ get visibleEntries() {
407
+ return this.sortFiles(this.filteredEntries);
408
+ },
409
+
410
+ clearSearch() {
411
+ this.searchQuery = "";
412
+ },
413
+
414
+ get selectedFiles() {
415
+ return this.browser.entries.filter((file) => file.selected && this.isSelectableEntry(file));
416
+ },
417
+
418
+ get selectableEntries() {
419
+ return this.filteredEntries.filter((file) => this.isSelectableEntry(file));
420
+ },
421
+
422
+ get selectedCount() {
423
+ return this.selectedFiles.length;
424
+ },
425
+
426
+ get selectedCountLabel() {
427
+ return `${this.selectedCount} ${this.selectedCount === 1 ? "item" : "items"} selected`;
428
+ },
429
+
430
+ get allVisibleSelected() {
431
+ return (
432
+ this.selectableEntries.length > 0 &&
433
+ this.selectableEntries.every((file) => file.selected)
434
+ );
435
+ },
436
+
437
+ get someVisibleSelected() {
438
+ return this.selectableEntries.some((file) => file.selected);
439
+ },
440
+
441
+ toggleSelectAllVisible() {
442
+ const shouldSelect = !this.allVisibleSelected;
443
+ this.selectableEntries.forEach((file) => {
444
+ file.selected = shouldSelect;
445
+ });
446
+ },
447
+
448
+ clearSelection() {
449
+ this.browser.entries.forEach((file) => {
450
+ file.selected = false;
451
+ });
452
+ },
453
+
454
+ isPickerMode() {
455
+ return this.pickerMode !== PICKER_MODE_NONE;
456
+ },
457
+
458
+ isTextOpenPicker() {
459
+ return this.pickerMode === PICKER_MODE_TEXT_OPEN;
460
+ },
461
+
462
+ isSaveAsPicker() {
463
+ return this.pickerMode === PICKER_MODE_SAVE_AS;
464
+ },
465
+
466
+ isSelectableEntry(file = {}) {
467
+ if (this.isSaveAsPicker()) return false;
468
+ if (this.isTextOpenPicker()) return !file?.is_dir && this.fileSurfaceTarget(file) === "editor";
469
+ return true;
470
+ },
471
+
472
+ normalizeOpeningPath(path) {
473
+ return String(path || "").trim();
474
+ },
475
+
476
+ normalizeSubmittedPath(path) {
477
+ const trimmed = String(path || "").trim();
478
+ if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
479
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
480
+ },
481
+
482
+ syncPathInput() {
483
+ this.pathInput = this.browser.currentPath || "";
484
+ },
485
+
486
+ resetPathInput() {
487
+ this.syncPathInput();
488
+ this.pathError = "";
489
+ },
490
+
491
+ async loadDirectoryPreference() {
492
+ if (this.settingsLoadPromise) return await this.settingsLoadPromise;
493
+
494
+ this.settingsLoadPromise = (async () => {
495
+ try {
496
+ const response = await callJsonApi("settings_get", null);
497
+ const remember = response?.settings?.file_browser_remember_last_directory;
498
+ this.rememberLastDirectory =
499
+ typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
500
+ } catch (error) {
501
+ console.warn("Failed to load file browser directory preference:", error);
502
+ this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
503
+ } finally {
504
+ if (!this.rememberLastDirectory) this.clearRememberedDirectory();
505
+ this.settingsLoadPromise = null;
506
+ }
507
+ return this.rememberLastDirectory;
508
+ })();
509
+
510
+ return await this.settingsLoadPromise;
511
+ },
512
+
513
+ getRememberedDirectory() {
514
+ if (!this.rememberLastDirectory) return "";
515
+ try {
516
+ return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
517
+ } catch {
518
+ return "";
519
+ }
520
+ },
521
+
522
+ rememberCurrentDirectory(path = this.browser.currentPath) {
523
+ if (!this.rememberLastDirectory) return;
524
+ const directory = this.normalizeOpeningPath(path);
525
+ if (!directory || directory === "$WORK_DIR") return;
526
+ try {
527
+ localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
528
+ } catch {}
529
+ },
530
+
531
+ clearRememberedDirectory() {
532
+ try {
533
+ localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
534
+ } catch {}
535
+ },
536
+
537
+ disposeScopedTooltips() {
538
+ const root = document.querySelector(".file-browser-root");
539
+ const tooltipApi = globalThis.bootstrap?.Tooltip;
540
+ if (!root || !tooltipApi) return;
541
+
542
+ root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
543
+ const instance = tooltipApi.getInstance(element);
544
+ try {
545
+ instance?.dispose();
546
+ } catch {}
547
+ });
548
+ document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
549
+ },
550
+
551
+ // --- Modal helpers -------------------------------------------------------
552
+ normalizePath(path) {
553
+ if (!path) return "";
554
+ return path.startsWith("/") ? path : `/${path}`;
555
+ },
556
+
557
+ fileExtension(file = {}) {
558
+ const name = String(file?.name || file?.path || "").split(/[?#]/, 1)[0].toLowerCase();
559
+ const index = name.lastIndexOf(".");
560
+ return index >= 0 ? name.slice(index + 1) : "";
561
+ },
562
+
563
+ fileSurfaceTarget(file = {}) {
564
+ if (!file || file.is_dir) return "";
565
+ const ext = this.fileExtension(file);
566
+ if (EDITOR_TEXT_EXTENSIONS.has(ext)) return "editor";
567
+ if (BROWSER_EXTENSIONS.has(ext)) return "browser";
568
+ if (DESKTOP_EXTENSIONS.has(ext)) return "desktop";
569
+ return "";
570
+ },
571
+
572
+ pickerAllowsEntry(file = {}) {
573
+ if (!this.isTextOpenPicker()) return true;
574
+ return Boolean(file?.is_dir || this.fileSurfaceTarget(file) === "editor");
575
+ },
576
+
577
+ pickerSelectedFiles() {
578
+ if (!this.isTextOpenPicker()) return [];
579
+ return this.selectedFiles.filter((file) => !file.is_dir && this.fileSurfaceTarget(file) === "editor");
580
+ },
581
+
582
+ pickerSelectionLabel() {
583
+ if (!this.isTextOpenPicker()) return "";
584
+ const count = this.pickerSelectedFiles().length;
585
+ if (!count) return "No text files selected";
586
+ return `${count} text ${count === 1 ? "file" : "files"} selected`;
587
+ },
588
+
589
+ normalizedEditorTextExtension(value = "") {
590
+ const ext = String(value || "").toLowerCase().trim().replace(/^\./, "");
591
+ return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md";
592
+ },
593
+
594
+ pickerFilenameValue() {
595
+ const raw = String(this.pickerFilename || "").trim();
596
+ if (!raw) return "";
597
+ const ext = this.fileExtension({ name: raw });
598
+ return ext ? raw : `${raw}.${this.pickerDefaultExtension || "md"}`;
599
+ },
600
+
601
+ validatePickerFilename(updateError = true) {
602
+ if (!this.isSaveAsPicker()) return true;
603
+ const raw = String(this.pickerFilename || "").trim();
604
+ const filename = this.pickerFilenameValue();
605
+ let error = "";
606
+ if (!raw) {
607
+ error = "File name is required.";
608
+ } else if (raw === "." || raw === "..") {
609
+ error = "File name cannot be '.' or '..'.";
610
+ } else if (raw.includes("/") || raw.includes("\\")) {
611
+ error = "File name cannot include path separators.";
612
+ } else if (!EDITOR_TEXT_EXTENSIONS.has(this.fileExtension({ name: filename }))) {
613
+ error = "Use a .md or .txt file name.";
614
+ } else if ((this.browser.entries || []).some((entry) => entry?.name === filename)) {
615
+ error = `An item named "${filename}" already exists.`;
616
+ }
617
+ if (updateError) this.pickerFilenameError = error;
618
+ return !error;
619
+ },
620
+
621
+ onPickerFilenameInput() {
622
+ if (this.pickerFilenameError) this.validatePickerFilename(true);
623
+ },
624
+
625
+ canConfirmPicker() {
626
+ if (this.isTextOpenPicker()) return this.pickerSelectedFiles().length > 0;
627
+ if (this.isSaveAsPicker()) return Boolean(this.pickerFilenameValue()) && !this.pickerFilenameError;
628
+ return false;
629
+ },
630
+
631
+ pickerTargetPath() {
632
+ if (!this.isSaveAsPicker()) return "";
633
+ return this.buildChildPath(this.pickerFilenameValue());
634
+ },
635
+
636
+ togglePickerFile(file = {}) {
637
+ if (!this.isTextOpenPicker() || file?.is_dir || this.fileSurfaceTarget(file) !== "editor") return;
638
+ file.selected = !file.selected;
639
+ },
640
+
641
+ async confirmPicker() {
642
+ if (!this.isPickerMode() || this.isBulkBusy) return;
643
+ if (this.isSaveAsPicker() && !this.validatePickerFilename(true)) return;
644
+ const payload = this.isSaveAsPicker()
645
+ ? {
646
+ mode: this.pickerMode,
647
+ directory: this.browser.currentPath,
648
+ filename: this.pickerFilenameValue(),
649
+ path: this.pickerTargetPath(),
650
+ }
651
+ : {
652
+ mode: this.pickerMode,
653
+ directory: this.browser.currentPath,
654
+ selectedFiles: this.pickerSelectedFiles(),
655
+ };
656
+ try {
657
+ this.isBulkBusy = true;
658
+ const result = await this.pickerOnConfirm?.(payload);
659
+ if (result === false) return;
660
+ this.disposeScopedTooltips();
661
+ window.closeModal(FILE_BROWSER_MODAL_PATH);
662
+ } catch (error) {
663
+ const message = error?.message || "File selection failed";
664
+ if (this.isSaveAsPicker()) this.pickerFilenameError = message;
665
+ window.toastFrontendError?.(message, "File Browser");
666
+ } finally {
667
+ this.isBulkBusy = false;
668
+ }
669
+ },
670
+
671
+ cancelPicker() {
672
+ this.disposeScopedTooltips();
673
+ window.closeModal(FILE_BROWSER_MODAL_PATH);
674
+ },
675
+
676
+ handleFileNameClick(file = {}) {
677
+ if (file?.is_dir) {
678
+ return this.navigateToFolder(file.path);
679
+ }
680
+ if (this.isTextOpenPicker()) {
681
+ this.togglePickerFile(file);
682
+ }
683
+ },
684
+
685
+ canOpenInSurface(file = {}) {
686
+ return Boolean(this.fileSurfaceTarget(file));
687
+ },
688
+
689
+ isEditorSurface(file = {}) {
690
+ return this.fileSurfaceTarget(file) === "editor";
691
+ },
692
+
693
+ canOpenInActionMenu(file = {}) {
694
+ const target = this.fileSurfaceTarget(file);
695
+ return Boolean(target && target !== "editor");
696
+ },
697
+
698
+ surfaceAction(file = {}) {
699
+ const target = this.fileSurfaceTarget(file);
700
+ return target ? SURFACE_ACTIONS[target] : null;
701
+ },
702
+
703
+ surfaceActionLabel(file = {}) {
704
+ return this.surfaceAction(file)?.label || "Open";
705
+ },
706
+
707
+ surfaceActionIcon(file = {}) {
708
+ return this.surfaceAction(file)?.icon || "open_in_new";
709
+ },
710
+
711
+ surfaceActionTitle(file = {}) {
712
+ return this.surfaceAction(file)?.title || "Open file";
713
+ },
714
+
715
+ fileUrl(file = {}) {
716
+ const path = this.normalizePath(String(file?.path || ""));
717
+ const encodedPath = path
718
+ .split("/")
719
+ .map((part) => encodeURIComponent(part))
720
+ .join("/");
721
+ return `file://${encodedPath}`;
722
+ },
723
+
724
+ storeHasPath(surfaceStore = {}, path = "") {
725
+ const normalizedPath = this.normalizePath(path);
726
+ const activePath = surfaceStore?.session?.path || surfaceStore?.session?.document?.path || "";
727
+ return this.normalizePath(activePath) === normalizedPath;
728
+ },
729
+
730
+ buildChildPath(name) {
731
+ const base = this.normalizePath(this.browser.currentPath || "");
732
+ const trimmedBase = base.replace(/\/$/, "");
733
+ if (!trimmedBase) return `/${name}`;
734
+ return `${trimmedBase}/${name}`;
735
+ },
736
+
737
+ parentPath(path) {
738
+ const normalized = this.normalizePath(String(path || "")).replace(/\/+$/, "");
739
+ const index = normalized.lastIndexOf("/");
740
+ if (index <= 0) return "/";
741
+ return normalized.slice(0, index);
742
+ },
743
+
744
+ siblingPath(path, name) {
745
+ const parent = this.parentPath(path);
746
+ return parent === "/" ? `/${name}` : `${parent}/${name}`;
747
+ },
748
+
749
+ resetRenameState() {
750
+ this.renameTarget = null;
751
+ this.renameName = "";
752
+ this.renameMode = "rename";
753
+ this.isRenaming = false;
754
+ this.renameError = null;
755
+ this.renameAfterConfirm = null;
756
+ this.renamePerformAction = null;
757
+ this.renameValidateName = null;
758
+ },
759
+
760
+ // --- Sorting -------------------------------------------------------------
761
+ toggleSort(column) {
762
+ if (this.browser.sortBy === column) {
763
+ this.browser.sortDirection =
764
+ this.browser.sortDirection === "asc" ? "desc" : "asc";
765
+ } else {
766
+ this.browser.sortBy = column;
767
+ this.browser.sortDirection = "asc";
768
+ }
769
+ },
770
+
771
+ sortFiles(entries) {
772
+ return [...entries].sort((a, b) => {
773
+ // Folders first
774
+ if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
775
+ const dir = this.browser.sortDirection === "asc" ? 1 : -1;
776
+ switch (this.browser.sortBy) {
777
+ case "name":
778
+ return dir * a.name.localeCompare(b.name);
779
+ case "size":
780
+ return dir * (a.size - b.size);
781
+ case "date":
782
+ return dir * (new Date(a.modified) - new Date(b.modified));
783
+ default:
784
+ return 0;
785
+ }
786
+ });
787
+ },
788
+
789
+ // --- Dropdown Management -------------------------------------------------
790
+ toggleDropdown(filePath, triggerElement = null) {
791
+ // Toggle: if already open, close it; otherwise open this one (closing any other)
792
+ if (this.openDropdownPath === filePath) {
793
+ this.closeDropdown();
794
+ return;
795
+ }
796
+ this.openDropdownPath = filePath;
797
+ this.dropdownStyle = this.getDropdownStyle(triggerElement);
798
+ },
799
+
800
+ isDropdownOpen(filePath) {
801
+ return this.openDropdownPath === filePath;
802
+ },
803
+
804
+ closeDropdown() {
805
+ this.openDropdownPath = null;
806
+ this.dropdownStyle = {};
807
+ },
808
+
809
+ getDropdownStyle(triggerElement) {
810
+ if (!triggerElement) return {};
811
+
812
+ const rect = triggerElement.getBoundingClientRect();
813
+ const gap = 6;
814
+ const padding = 8;
815
+ const minWidth = 180;
816
+ const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
817
+ const spaceAbove = rect.top - gap - padding;
818
+ const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
819
+ const maxHeight = Math.max(96, openUp ? spaceAbove : spaceBelow);
820
+ const maxLeft = Math.max(padding, window.innerWidth - minWidth - padding);
821
+ const left = Math.min(Math.max(rect.right - minWidth, padding), maxLeft);
822
+
823
+ return {
824
+ position: "fixed",
825
+ left: `${Math.round(left)}px`,
826
+ right: "auto",
827
+ top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
828
+ bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
829
+ minWidth: `${minWidth}px`,
830
+ maxHeight: `${Math.round(maxHeight)}px`,
831
+ zIndex: "6000",
832
+ };
833
+ },
834
+
835
+ // --- Navigation ----------------------------------------------------------
836
+ async fetchFiles(path = "", options = {}) {
837
+ const preserveOnError = options?.preserveOnError === true;
838
+ const suppressErrorToast = options?.suppressErrorToast === true;
839
+ const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";
840
+ this.isLoading = true;
841
+
842
+ // Preserve scroll position if refreshing the same path
843
+ const isSamePath =
844
+ this.browser.currentPath === requestedPath ||
845
+ (requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath));
846
+ const scrollPos = isSamePath ? this.saveScrollPosition() : null;
847
+ const selectedPaths = isSamePath
848
+ ? new Set(this.selectedFiles.map((file) => file.path))
849
+ : new Set();
850
+
851
+ try {
852
+ const response = await fetchApi(
853
+ `/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`
854
+ );
855
+ const data = await response.json().catch(() => ({}));
856
+
857
+ const result = data.data || {};
858
+ const entries = result.entries || [];
859
+ const resolvedCurrentPath =
860
+ result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath);
861
+ const resultError =
862
+ data.error ||
863
+ result.error ||
864
+ (
865
+ requestedPath &&
866
+ requestedPath !== "$WORK_DIR" &&
867
+ !result.current_path &&
868
+ !entries.length
869
+ ? "Directory not found or not accessible"
870
+ : ""
871
+ );
872
+
873
+ if (response.ok && !resultError) {
874
+ if (!isSamePath) this.searchQuery = "";
875
+ this.browser.entries = this.decorateEntries(
876
+ entries,
877
+ selectedPaths
878
+ );
879
+ this.browser.currentPath = resolvedCurrentPath;
880
+ this.browser.parentPath = result.parent_path;
881
+ this.syncPathInput();
882
+ this.pathError = "";
883
+ this.rememberCurrentDirectory(this.browser.currentPath);
884
+
885
+ // Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
886
+ this.isLoading = false;
887
+
888
+ // Restore scroll position if on same path
889
+ if (scrollPos) {
890
+ this.restoreScrollPosition(scrollPos);
891
+ }
892
+ return true;
893
+ } else {
894
+ const msg = resultError || "Error fetching files";
895
+ console.error("Error fetching files:", msg);
896
+ if (!preserveOnError) this.browser.entries = [];
897
+ this.isLoading = false;
898
+ if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
899
+ return false;
900
+ }
901
+ } catch (e) {
902
+ const message = "Error fetching files: " + e.message;
903
+ if (!suppressErrorToast) {
904
+ window.toastFrontendError(message, "File Browser Error");
905
+ }
906
+ if (!preserveOnError) this.browser.entries = [];
907
+ this.isLoading = false;
908
+ return false;
909
+ }
910
+ },
911
+
912
+ async navigateToFolder(path) {
913
+ if(!path.startsWith("/")) path = "/" + path;
914
+ if (this.browser.currentPath !== path)
915
+ this.history.push(this.browser.currentPath);
916
+ await this.fetchFiles(path);
917
+ },
918
+
919
+ async submitPath() {
920
+ if (this.isPathSubmitting || this.isLoading) return;
921
+
922
+ const path = this.normalizeSubmittedPath(this.pathInput);
923
+ if (!path) {
924
+ this.pathError = "Enter a directory path.";
925
+ return;
926
+ }
927
+
928
+ this.isPathSubmitting = true;
929
+ this.pathError = "";
930
+
931
+ try {
932
+ const previousPath = this.browser.currentPath;
933
+ const loaded = await this.fetchFiles(path, {
934
+ preserveOnError: true,
935
+ suppressErrorToast: true,
936
+ });
937
+
938
+ if (loaded) {
939
+ if (previousPath && previousPath !== this.browser.currentPath) {
940
+ this.history.push(previousPath);
941
+ }
942
+ return;
943
+ }
944
+
945
+ this.pathError = "Directory not found or not accessible.";
946
+ } finally {
947
+ this.isPathSubmitting = false;
948
+ }
949
+ },
950
+
951
+ async navigateUp() {
952
+ if (this.browser.parentPath) {
953
+ this.history.push(this.browser.currentPath);
954
+ await this.fetchFiles(this.browser.parentPath);
955
+ }
956
+ },
957
+
958
+ // --- Drag and drop ------------------------------------------------------
959
+ startDrag(file = {}, event) {
960
+ if (this.isPickerMode() || this.isBulkBusy || !file?.path || !event?.dataTransfer) {
961
+ event?.preventDefault();
962
+ return;
963
+ }
964
+ this.draggedPaths = file.selected
965
+ ? this.selectedFiles.map((entry) => entry.path)
966
+ : [file.path];
967
+ event.dataTransfer.effectAllowed = "move";
968
+ event.dataTransfer.setData("application/x-agent-zero-files", JSON.stringify(this.draggedPaths));
969
+ event.dataTransfer.setData("text/plain", this.draggedPaths.join("\n"));
970
+ this.closeDropdown();
971
+ },
972
+
973
+ isDraggingPath(path = "") {
974
+ return this.draggedPaths.includes(path);
975
+ },
976
+
977
+ canDropAt(destinationPath = "") {
978
+ const destination = this.normalizePath(destinationPath).replace(/\/+$/, "") || "/";
979
+ return Boolean(this.draggedPaths.length && this.draggedPaths.every((path) => {
980
+ const source = this.normalizePath(path).replace(/\/+$/, "") || "/";
981
+ return destination !== source && !destination.startsWith(`${source}/`);
982
+ }));
983
+ },
984
+
985
+ setDropTarget(destinationPath, event) {
986
+ if (!this.canDropAt(destinationPath)) {
987
+ if (event?.dataTransfer) event.dataTransfer.dropEffect = "none";
988
+ return;
989
+ }
990
+ event.preventDefault();
991
+ event.dataTransfer.dropEffect = "move";
992
+ this.dragOverPath = destinationPath;
993
+ },
994
+
995
+ clearDropTarget(destinationPath, event) {
996
+ if (event?.currentTarget?.contains(event.relatedTarget)) return;
997
+ if (this.dragOverPath === destinationPath) this.dragOverPath = "";
998
+ },
999
+
1000
+ clearDragState() {
1001
+ this.draggedPaths = [];
1002
+ this.dragOverPath = "";
1003
+ },
1004
+
1005
+ async dropItems(destinationPath, destinationName, event) {
1006
+ if (!this.canDropAt(destinationPath)) return;
1007
+ event.preventDefault();
1008
+ const paths = [...this.draggedPaths];
1009
+ const selectedPaths = new Set(this.selectedFiles.map((file) => file.path));
1010
+ this.clearDragState();
1011
+ this.isBulkBusy = true;
1012
+
1013
+ try {
1014
+ const resp = await fetchApi("/rename_work_dir_file", {
1015
+ method: "POST",
1016
+ headers: { "Content-Type": "application/json" },
1017
+ body: JSON.stringify({
1018
+ action: "move",
1019
+ paths,
1020
+ destinationPath,
1021
+ currentPath: this.browser.currentPath,
1022
+ }),
1023
+ });
1024
+ const data = await resp.json().catch(() => ({}));
1025
+ if (!resp.ok || data.error) throw new Error(data.error || "Move failed");
1026
+
1027
+ this.browser.entries = this.decorateEntries(data.data?.entries || [], selectedPaths);
1028
+ this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1029
+ this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1030
+ const count = paths.length;
1031
+ window.toastFrontendSuccess(
1032
+ `Moved ${count} ${count === 1 ? "item" : "items"} to ${destinationName}`,
1033
+ "Files Moved"
1034
+ );
1035
+ } catch (error) {
1036
+ window.toastFrontendError(error?.message || "Move failed", "Move Error");
1037
+ } finally {
1038
+ this.isBulkBusy = false;
1039
+ }
1040
+ },
1041
+
1042
+ // --- Rename / Create -----------------------------------------------------
1043
+ async openRenameModal(file, options = {}) {
1044
+ this.resetRenameState();
1045
+ this.renameTarget = file;
1046
+ this.renameName = file?.name || "";
1047
+ this.renameMode = "rename";
1048
+ this.renameError = null;
1049
+ this.renameAfterConfirm = typeof options.onRenamed === "function" ? options.onRenamed : null;
1050
+ this.renamePerformAction = typeof options.performRename === "function" ? options.performRename : null;
1051
+ this.renameValidateName = typeof options.validateName === "function" ? options.validateName : null;
1052
+ if (typeof options.currentPath === "string" && options.currentPath) {
1053
+ this.browser.currentPath = options.currentPath;
1054
+ }
1055
+ if (Array.isArray(options.entries)) {
1056
+ this.browser.entries = options.entries;
1057
+ }
1058
+ window.openModal("modals/file-browser/rename-modal.html");
1059
+ },
1060
+
1061
+ async openNewFolderModal() {
1062
+ this.resetRenameState();
1063
+ this.renameMode = "create-folder";
1064
+ this.renameName = "";
1065
+ this.renameError = null;
1066
+ window.openModal("modals/file-browser/rename-modal.html");
1067
+ },
1068
+
1069
+ closeRenameModal() {
1070
+ window.closeModal("modals/file-browser/rename-modal.html");
1071
+ },
1072
+
1073
+ async confirmRename() {
1074
+ if (this.isRenaming) return;
1075
+
1076
+ const newName = this.renameName.trim();
1077
+ if (!newName) {
1078
+ this.renameError = "Name is required.";
1079
+ return;
1080
+ }
1081
+ if (newName === "." || newName === "..") {
1082
+ this.renameError = "Name cannot be '.' or '..'.";
1083
+ return;
1084
+ }
1085
+ if (newName.includes("/") || newName.includes("\\")) {
1086
+ this.renameError = "Name cannot include path separators.";
1087
+ return;
1088
+ }
1089
+ if (this.renameMode !== "create-folder" && !this.renameTarget?.path) {
1090
+ this.renameError = "No item selected for rename.";
1091
+ return;
1092
+ }
1093
+ if (this.renameValidateName) {
1094
+ const validation = this.renameValidateName(newName, this.renameTarget);
1095
+ if (validation !== true) {
1096
+ this.renameError = typeof validation === "string" ? validation : "Name is not valid.";
1097
+ return;
1098
+ }
1099
+ }
1100
+
1101
+ // UX: pre-validate duplicates so we can show a clean inline error (no toast spam)
1102
+ const duplicate = (this.browser.entries || []).some((entry) => {
1103
+ if (!entry?.name) return false;
1104
+ if (entry.name !== newName) return false;
1105
+ // When renaming, allow keeping the same entry name
1106
+ if (this.renameTarget?.path && entry.path === this.renameTarget.path) return false;
1107
+ return true;
1108
+ });
1109
+ if (duplicate) {
1110
+ this.renameError = `An item named "${newName}" already exists.`;
1111
+ return;
1112
+ }
1113
+
1114
+ this.isRenaming = true;
1115
+ this.renameError = null;
1116
+
1117
+ try {
1118
+ const previousPath = this.renameTarget?.path || "";
1119
+ const renamedPath =
1120
+ this.renameMode === "create-folder"
1121
+ ? this.buildChildPath(newName)
1122
+ : this.siblingPath(previousPath, newName);
1123
+ const payload =
1124
+ this.renameMode === "create-folder"
1125
+ ? {
1126
+ action: "create-folder",
1127
+ parentPath: this.browser.currentPath,
1128
+ currentPath: this.browser.currentPath,
1129
+ newName: newName,
1130
+ }
1131
+ : {
1132
+ action: "rename",
1133
+ path: this.renameTarget?.path,
1134
+ currentPath: this.browser.currentPath,
1135
+ newName: newName,
1136
+ };
1137
+
1138
+ let data = {};
1139
+ if (this.renamePerformAction) {
1140
+ data = await this.renamePerformAction({
1141
+ action: this.renameMode,
1142
+ previousPath,
1143
+ path: renamedPath,
1144
+ name: newName,
1145
+ target: this.renameTarget,
1146
+ payload,
1147
+ }) || {};
1148
+ if (data.error || data.ok === false) {
1149
+ throw new Error(data.error || "Rename failed");
1150
+ }
1151
+ } else {
1152
+ const resp = await fetchApi("/rename_work_dir_file", {
1153
+ method: "POST",
1154
+ headers: { "Content-Type": "application/json" },
1155
+ body: JSON.stringify(payload),
1156
+ });
1157
+
1158
+ data = await resp.json().catch(() => ({}));
1159
+ if (!resp.ok || data.error) {
1160
+ throw new Error(data.error || "Rename failed");
1161
+ }
1162
+ }
1163
+
1164
+ if (!this.renamePerformAction || data.refreshFiles !== false) {
1165
+ await this.fetchFiles(this.browser.currentPath);
1166
+ }
1167
+ if (this.renameAfterConfirm) {
1168
+ await this.renameAfterConfirm({
1169
+ action: this.renameMode,
1170
+ previousPath,
1171
+ path: renamedPath,
1172
+ name: newName,
1173
+ target: this.renameTarget,
1174
+ response: data,
1175
+ });
1176
+ }
1177
+ this.closeRenameModal();
1178
+ } catch (error) {
1179
+ const message = error?.message || "Rename failed";
1180
+ this.renameError = message;
1181
+ const title =
1182
+ this.renameMode === "create-folder" ? "Folder Error" : "Rename Error";
1183
+ window.toastFrontendError(message, title);
1184
+ } finally {
1185
+ this.isRenaming = false;
1186
+ }
1187
+ },
1188
+
1189
+ // --- File Editor (Delegated to FileEditorStore) --------------------------
1190
+ async openFileEditor(file) {
1191
+ await fileEditorStore.openFile(file, async () => {
1192
+ // Callback on successful save to refresh file list
1193
+ await this.fetchFiles(this.browser.currentPath);
1194
+ });
1195
+ },
1196
+
1197
+ async openNewFile() {
1198
+ const existingNames = (this.browser.entries || [])
1199
+ .map((e) => e?.name)
1200
+ .filter(Boolean);
1201
+ await fileEditorStore.openNewFile(this.browser.currentPath, existingNames, async () => {
1202
+ // Callback on successful save to refresh file list
1203
+ await this.fetchFiles(this.browser.currentPath);
1204
+ });
1205
+ },
1206
+
1207
+ // --- File actions --------------------------------------------------------
1208
+ async extractArchive(file = {}) {
1209
+ if (!file?.path || !this.isArchive(file.name) || this.isBulkBusy) return;
1210
+ this.isBulkBusy = true;
1211
+ this.closeDropdown();
1212
+ try {
1213
+ const resp = await fetchApi("/extract_work_dir_archive", {
1214
+ method: "POST",
1215
+ headers: { "Content-Type": "application/json" },
1216
+ body: JSON.stringify({ path: file.path, currentPath: this.browser.currentPath }),
1217
+ });
1218
+ const data = await resp.json().catch(() => ({}));
1219
+ if (!resp.ok || data.error) throw new Error(data.error || "Archive extraction failed");
1220
+ this.browser.entries = this.decorateEntries(data.data?.entries || []);
1221
+ this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1222
+ this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1223
+ window.toastFrontendSuccess(`Extracted to ${data.extracted_path || "a new folder"}`, "Archive Extracted");
1224
+ } catch (error) {
1225
+ window.toastFrontendError(error?.message || "Archive extraction failed", "Archive Extract Error");
1226
+ } finally {
1227
+ this.isBulkBusy = false;
1228
+ }
1229
+ },
1230
+
1231
+ async deleteFile(file) {
1232
+ try {
1233
+ const resp = await fetchApi("/delete_work_dir_file", {
1234
+ method: "POST",
1235
+ headers: { "Content-Type": "application/json" },
1236
+ body: JSON.stringify({
1237
+ path: file.path,
1238
+ currentPath: this.browser.currentPath,
1239
+ }),
1240
+ });
1241
+ const data = await resp.json().catch(() => ({}));
1242
+ if (resp.ok && !data.error) {
1243
+ this.browser.entries = this.browser.entries.filter(
1244
+ (e) => e.path !== file.path
1245
+ );
1246
+ window.toastFrontendSuccess("File deleted successfully", "File Deleted");
1247
+ } else {
1248
+ window.toastFrontendError(data.error || "Error deleting file", "Delete Error");
1249
+ }
1250
+ } catch (e) {
1251
+ window.toastFrontendError(
1252
+ "Error deleting file: " + e.message,
1253
+ "File Delete Error"
1254
+ );
1255
+ }
1256
+ },
1257
+
1258
+ copySelectedPaths() {
1259
+ const selectedFiles = this.selectedFiles;
1260
+ if (!selectedFiles.length) return;
1261
+
1262
+ const paths = selectedFiles.map((file) => file.path).join("\n");
1263
+ this.copyToClipboard(paths, () => {
1264
+ window.toastFrontendSuccess(
1265
+ `Copied ${selectedFiles.length} ${selectedFiles.length === 1 ? "path" : "paths"}`,
1266
+ "File Browser"
1267
+ );
1268
+ });
1269
+ },
1270
+
1271
+ copyToClipboard(text, onSuccess) {
1272
+ if (navigator.clipboard && window.isSecureContext) {
1273
+ navigator.clipboard
1274
+ .writeText(text)
1275
+ .then(() => onSuccess?.())
1276
+ .catch(() => this.fallbackCopyToClipboard(text, onSuccess));
1277
+ } else {
1278
+ this.fallbackCopyToClipboard(text, onSuccess);
1279
+ }
1280
+ },
1281
+
1282
+ fallbackCopyToClipboard(text, onSuccess) {
1283
+ const textArea = document.createElement("textarea");
1284
+ textArea.value = text;
1285
+ textArea.style.position = "fixed";
1286
+ textArea.style.left = "-999999px";
1287
+ textArea.style.top = "-999999px";
1288
+ document.body.appendChild(textArea);
1289
+ textArea.focus();
1290
+ textArea.select();
1291
+ try {
1292
+ document.execCommand("copy");
1293
+ onSuccess?.();
1294
+ } catch (error) {
1295
+ console.error("Clipboard copy failed:", error);
1296
+ window.toastFrontendError("Failed to copy selected paths", "File Browser");
1297
+ } finally {
1298
+ document.body.removeChild(textArea);
1299
+ }
1300
+ },
1301
+
1302
+ getDownloadFilename(response, fallback) {
1303
+ const disposition = response.headers.get("Content-Disposition") || "";
1304
+ const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
1305
+ if (utf8Match?.[1]) {
1306
+ try {
1307
+ return decodeURIComponent(utf8Match[1].replace(/^"|"$/g, ""));
1308
+ } catch {
1309
+ return utf8Match[1].replace(/^"|"$/g, "");
1310
+ }
1311
+ }
1312
+
1313
+ const asciiMatch = disposition.match(/filename="([^"]+)"/i);
1314
+ return asciiMatch?.[1] || fallback;
1315
+ },
1316
+
1317
+ createDownloadToastGroup(prefix) {
1318
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1319
+ },
1320
+
1321
+ showDownloadPreparingToast(group) {
1322
+ window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);
1323
+ },
1324
+
1325
+ showDownloadStartedToast(group) {
1326
+ window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);
1327
+ },
1328
+
1329
+ showDownloadErrorToast(group, message) {
1330
+ window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);
1331
+ },
1332
+
1333
+ async bulkDownloadFiles() {
1334
+ const selectedFiles = this.selectedFiles;
1335
+ if (!selectedFiles.length || this.isBulkBusy) return;
1336
+
1337
+ this.isBulkBusy = true;
1338
+ this.closeDropdown();
1339
+ const downloadToastGroup = this.createDownloadToastGroup("file-browser-bulk-download");
1340
+
1341
+ try {
1342
+ this.showDownloadPreparingToast(downloadToastGroup);
1343
+ const resp = await fetchApi("/download_work_dir_files", {
1344
+ method: "POST",
1345
+ headers: { "Content-Type": "application/json" },
1346
+ body: JSON.stringify({
1347
+ paths: selectedFiles.map((file) => file.path),
1348
+ currentPath: this.browser.currentPath,
1349
+ }),
1350
+ });
1351
+
1352
+ if (!resp.ok) {
1353
+ const message = await resp.text();
1354
+ throw new Error(message || "Download failed");
1355
+ }
1356
+
1357
+ const blob = await resp.blob();
1358
+ const url = URL.createObjectURL(blob);
1359
+ const fallback = `agent-zero-files-${selectedFiles.length}.zip`;
1360
+ const link = document.createElement("a");
1361
+ link.href = url;
1362
+ link.download = this.getDownloadFilename(resp, fallback);
1363
+ document.body.appendChild(link);
1364
+ link.click();
1365
+ document.body.removeChild(link);
1366
+ setTimeout(() => URL.revokeObjectURL(url), 0);
1367
+
1368
+ this.showDownloadStartedToast(downloadToastGroup);
1369
+ } catch (error) {
1370
+ this.showDownloadErrorToast(
1371
+ downloadToastGroup,
1372
+ error?.message || "Failed to download selected files"
1373
+ );
1374
+ } finally {
1375
+ this.isBulkBusy = false;
1376
+ }
1377
+ },
1378
+
1379
+ async bulkDeleteFiles() {
1380
+ const selectedFiles = this.selectedFiles;
1381
+ if (!selectedFiles.length || this.isBulkBusy) return;
1382
+
1383
+ this.isBulkBusy = true;
1384
+ this.closeDropdown();
1385
+
1386
+ try {
1387
+ const resp = await fetchApi("/delete_work_dir_files", {
1388
+ method: "POST",
1389
+ headers: { "Content-Type": "application/json" },
1390
+ body: JSON.stringify({
1391
+ paths: selectedFiles.map((file) => file.path),
1392
+ currentPath: this.browser.currentPath,
1393
+ }),
1394
+ });
1395
+ const data = await resp.json().catch(() => ({}));
1396
+
1397
+ if (resp.ok && !data.error) {
1398
+ this.browser.entries = this.decorateEntries(data.data?.entries || []);
1399
+ this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1400
+ this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1401
+ const deletedCount = data.deleted?.length || selectedFiles.length;
1402
+ window.toastFrontendSuccess(
1403
+ `Deleted ${deletedCount} ${deletedCount === 1 ? "item" : "items"}`,
1404
+ "File Browser"
1405
+ );
1406
+
1407
+ if (data.failed?.length) {
1408
+ window.toastFrontendError(
1409
+ `${data.failed.length} selected ${data.failed.length === 1 ? "item" : "items"} could not be deleted`,
1410
+ "File Browser"
1411
+ );
1412
+ }
1413
+ } else {
1414
+ window.toastFrontendError(
1415
+ data.error || "Error deleting selected files",
1416
+ "File Browser"
1417
+ );
1418
+ }
1419
+ } catch (error) {
1420
+ window.toastFrontendError(
1421
+ "Error deleting selected files: " + error.message,
1422
+ "File Browser"
1423
+ );
1424
+ } finally {
1425
+ this.isBulkBusy = false;
1426
+ }
1427
+ },
1428
+
1429
+ async handleFileUpload(event) {
1430
+ return store._handleFileUpload(event); // bind to model to ensure correct context
1431
+ },
1432
+
1433
+ async openInSurface(file = {}) {
1434
+ const target = this.fileSurfaceTarget(file);
1435
+ const path = this.normalizePath(String(file?.path || ""));
1436
+ if (!target || !path) return;
1437
+
1438
+ this.closeDropdown();
1439
+
1440
+ try {
1441
+ if (target === "browser") {
1442
+ const url = this.fileUrl(file);
1443
+ const { store: browserStore } = await import("/plugins/_browser/webui/browser-store.js");
1444
+ await openLatestSurface("browser", { url, source: "file-browser" });
1445
+
1446
+ let opened = false;
1447
+ for (let attempt = 0; attempt < 40 && !opened; attempt += 1) {
1448
+ opened = await browserStore.openUrlIntent(url, { source: "file-browser" });
1449
+ if (!opened) await delay(75);
1450
+ }
1451
+ if (!opened) {
1452
+ throw new Error("Browser surface is unavailable.");
1453
+ }
1454
+ } else {
1455
+ await openLatestSurface(target, { path, source: "file-browser" });
1456
+ if (target === "editor") {
1457
+ const { store: editorStore } = await import("/plugins/_editor/webui/editor-store.js");
1458
+ if (!this.storeHasPath(editorStore, path)) {
1459
+ const session = await editorStore.openPath(path, { source: "file-browser" });
1460
+ if (!session || session.ok === false) {
1461
+ throw new Error(editorStore.error || "Text document could not be opened.");
1462
+ }
1463
+ }
1464
+ }
1465
+ if (target === "desktop") {
1466
+ const { store: desktopStore } = await import("/plugins/_desktop/webui/desktop-store.js");
1467
+ if (!this.storeHasPath(desktopStore, path)) {
1468
+ const session = await desktopStore.openPath(path);
1469
+ if (!session || session.ok === false) {
1470
+ throw new Error(desktopStore.error || "Document could not be opened.");
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ this.disposeScopedTooltips();
1477
+ await window.closeModal?.(FILE_BROWSER_MODAL_PATH);
1478
+ } catch (error) {
1479
+ window.toastFrontendError?.(
1480
+ error?.message || "Could not open file",
1481
+ "File Browser"
1482
+ );
1483
+ }
1484
+ },
1485
+
1486
+ async _handleFileUpload(event) {
1487
+ try {
1488
+ const files = event.target.files;
1489
+ if (!files.length) return;
1490
+ const formData = new FormData();
1491
+ formData.append("path", this.browser.currentPath);
1492
+ for (let f of files) {
1493
+ const ext = f.name.split(".").pop().toLowerCase();
1494
+ if (
1495
+ !["zip", "tar", "gz", "rar", "7z"].includes(ext) &&
1496
+ f.size > 100 * 1024 * 1024
1497
+ ) {
1498
+ alert(`File ${f.name} exceeds 100MB limit.`);
1499
+ continue;
1500
+ }
1501
+ formData.append("files[]", f);
1502
+ }
1503
+ const resp = await fetchApi("/upload_work_dir_files", {
1504
+ method: "POST",
1505
+ body: formData,
1506
+ });
1507
+ const data = await resp.json().catch(() => ({}));
1508
+ if (resp.ok && !data.error) {
1509
+ this.browser.entries = this.decorateEntries(data.data.entries || []);
1510
+ this.browser.currentPath = data.data.current_path;
1511
+ this.browser.parentPath = data.data.parent_path;
1512
+ if (data.failed && data.failed.length) {
1513
+ const msg = data.failed
1514
+ .map((f) => `${f.name}: ${f.error}`)
1515
+ .join("\n");
1516
+ alert(`Some files failed to upload:\n${msg}`);
1517
+ }
1518
+ } else {
1519
+ alert(data.error || "Error uploading files");
1520
+ }
1521
+ } catch (e) {
1522
+ window.toastFrontendError(
1523
+ "Error uploading files: " + e.message,
1524
+ "File Upload Error"
1525
+ );
1526
+ } finally {
1527
+ event.target.value = ""; // reset input so same file can be reselected
1528
+ }
1529
+ },
1530
+
1531
+ async downloadDirectory(file) {
1532
+ const downloadToastGroup = this.createDownloadToastGroup("file-browser-directory-download");
1533
+
1534
+ try {
1535
+ this.showDownloadPreparingToast(downloadToastGroup);
1536
+ const resp = await fetchApi(`/download_work_dir_file?path=${encodeURIComponent(file.path)}`, {
1537
+ method: "GET",
1538
+ });
1539
+
1540
+ if (!resp.ok) {
1541
+ const message = await resp.text();
1542
+ throw new Error(message || "Download failed");
1543
+ }
1544
+
1545
+ const blob = await resp.blob();
1546
+ const url = URL.createObjectURL(blob);
1547
+ const fallback = `${file.name}.zip`;
1548
+ const link = document.createElement("a");
1549
+ link.href = url;
1550
+ link.download = this.getDownloadFilename(resp, fallback);
1551
+ document.body.appendChild(link);
1552
+ link.click();
1553
+ document.body.removeChild(link);
1554
+ setTimeout(() => URL.revokeObjectURL(url), 0);
1555
+ this.showDownloadStartedToast(downloadToastGroup);
1556
+ } catch (error) {
1557
+ this.showDownloadErrorToast(
1558
+ downloadToastGroup,
1559
+ error?.message || "Failed to download directory"
1560
+ );
1561
+ }
1562
+ },
1563
+
1564
+ downloadFile(file) {
1565
+ if (file.is_dir) {
1566
+ return this.downloadDirectory(file);
1567
+ }
1568
+
1569
+ const link = document.createElement("a");
1570
+ link.href = `/api/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
1571
+ link.download = file.name;
1572
+ document.body.appendChild(link);
1573
+ link.click();
1574
+ document.body.removeChild(link);
1575
+ },
1576
+ };
1577
+
1578
+ export const store = createStore("fileBrowser", model);
1579
+
1580
+ window.openFileLink = async function (path) {
1581
+ try {
1582
+ const resp = await window.sendJsonData("/file_info", { path });
1583
+ if (!resp.exists) {
1584
+ window.toastFrontendError("File does not exist.", "File Error");
1585
+ return;
1586
+ }
1587
+ if (resp.is_dir) {
1588
+ // Set initial path and open via store
1589
+ await store.open(resp.abs_path);
1590
+ } else {
1591
+ store.downloadFile({ path: resp.abs_path, name: resp.file_name });
1592
+ }
1593
+ } catch (e) {
1594
+ window.toastFrontendError(
1595
+ "Error opening file: " + e.message,
1596
+ "File Open Error"
1597
+ );
1598
+ }
1599
+ };
webui/components/modals/file-browser/file-browser.html CHANGED
@@ -1,3 +1,1154 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0cb148d6976048127eb8a01c92cf746cd1a44db74fe9a8824cf9a5dd33b076e8
3
- size 39665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html
2
+ class="surface-modal file-browser-modal modal-no-backdrop"
3
+ data-surface-id="files"
4
+ data-surface-modal-path="modals/file-browser/file-browser.html"
5
+ data-surface-dock-title="Open Files in canvas"
6
+ data-surface-dock-icon="dock_to_right"
7
+ data-canvas-surface="files"
8
+ data-canvas-modal-path="modals/file-browser/file-browser.html"
9
+ data-canvas-dock-title="Open Files in canvas"
10
+ data-canvas-dock-icon="dock_to_right"
11
+ >
12
+ <head>
13
+ <title>File Browser</title>
14
+ <script type="module">
15
+ import { store } from "/components/modals/file-browser/file-browser-store.js";
16
+ </script>
17
+ </head>
18
+ <body class="file-browser-modal-body">
19
+ <div
20
+ x-data
21
+ class="file-browser-shell"
22
+ :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }"
23
+ x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"
24
+ x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"
25
+ >
26
+ <template x-if="$store.fileBrowser">
27
+ <div class="file-browser-root" :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
28
+
29
+ <!-- Loading State -->
30
+ <div x-show="$store.fileBrowser.isLoading" class="loading-state">
31
+ <div class="loading-spinner"></div>
32
+ <p>Loading files...</p>
33
+ </div>
34
+
35
+ <!-- File Browser Content -->
36
+ <div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
37
+ <!-- Path navigator -->
38
+ <div class="path-navigator-wrap">
39
+ <form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
40
+ <button
41
+ type="button"
42
+ class="nav-button back-button"
43
+ @click="$store.fileBrowser.navigateUp()"
44
+ @dragover="$store.fileBrowser.setDropTarget($store.fileBrowser.browser.parentPath, $event)"
45
+ @dragleave="$store.fileBrowser.clearDropTarget($store.fileBrowser.browser.parentPath, $event)"
46
+ @drop="$store.fileBrowser.dropItems($store.fileBrowser.browser.parentPath, 'parent folder', $event)"
47
+ :class="{ 'is-drop-target': $store.fileBrowser.dragOverPath === $store.fileBrowser.browser.parentPath }"
48
+ :disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
49
+ aria-label="Navigate up"
50
+ title="Navigate up"
51
+ >
52
+ <x-icon aria-hidden="true" name="arrow_upward"></x-icon>
53
+ <span class="nav-button-label">Up</span>
54
+ </button>
55
+ <div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
56
+ <input
57
+ id="current-path"
58
+ class="path-input"
59
+ type="text"
60
+ x-model="$store.fileBrowser.pathInput"
61
+ @focus="$event.target.select()"
62
+ @keydown.escape.stop.prevent="$store.fileBrowser.resetPathInput()"
63
+ :aria-invalid="$store.fileBrowser.pathError ? 'true' : 'false'"
64
+ aria-label="Directory path"
65
+ spellcheck="false"
66
+ autocomplete="off"
67
+ />
68
+ <button
69
+ type="submit"
70
+ class="btn-icon-action path-submit"
71
+ :disabled="$store.fileBrowser.isPathSubmitting || $store.fileBrowser.isLoading"
72
+ aria-label="Go to directory"
73
+ >
74
+ <x-icon name="arrow_forward"></x-icon>
75
+ </button>
76
+ </div>
77
+ </form>
78
+ <template x-if="$store.fileBrowser.pathError">
79
+ <div class="path-error" x-text="$store.fileBrowser.pathError"></div>
80
+ </template>
81
+ </div>
82
+ <div class="file-browser-toolbar">
83
+ <div class="file-search-shell">
84
+ <x-icon class="file-search-icon" aria-hidden="true" name="search"></x-icon>
85
+ <input
86
+ type="search"
87
+ class="file-search-input"
88
+ x-model="$store.fileBrowser.searchQuery"
89
+ @keydown.escape="$store.fileBrowser.clearSearch()"
90
+ placeholder="Search files..."
91
+ aria-label="Search files"
92
+ />
93
+ <button
94
+ type="button"
95
+ class="btn-icon-action file-search-clear"
96
+ x-show="$store.fileBrowser.searchQuery"
97
+ @click="$store.fileBrowser.clearSearch()"
98
+ aria-label="Clear search"
99
+ title="Clear search"
100
+ >
101
+ <x-icon name="close"></x-icon>
102
+ </button>
103
+ </div>
104
+
105
+ <div class="new-item-buttons">
106
+ <button
107
+ type="button"
108
+ class="btn btn-ok btn-new-item"
109
+ x-show="!$store.fileBrowser.isPickerMode()"
110
+ @click="$store.fileBrowser.openNewFile()"
111
+ aria-label="New file"
112
+ title="New file"
113
+ >
114
+ <x-icon aria-hidden="true" name="note_add"></x-icon>
115
+ </button>
116
+ <button
117
+ type="button"
118
+ class="btn btn-ok btn-new-item"
119
+ x-show="!$store.fileBrowser.isTextOpenPicker()"
120
+ @click="$store.fileBrowser.openNewFolderModal()"
121
+ aria-label="New folder"
122
+ title="New folder"
123
+ >
124
+ <x-icon aria-hidden="true" name="create_new_folder"></x-icon>
125
+ </button>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="data-status-bar data-status-bar-standalone file-status-bar">
130
+ <div class="status-info">
131
+ <span class="status-item">
132
+ <x-icon name="folder"></x-icon>
133
+ Total: <strong x-text="$store.fileBrowser.browser.entries.length"></strong>
134
+ </span>
135
+ <span class="status-separator">•</span>
136
+ <span class="status-item">
137
+ Filtered: <strong x-text="$store.fileBrowser.filteredEntries.length"></strong>
138
+ </span>
139
+ <template x-if="$store.fileBrowser.selectedCount > 0">
140
+ <span class="status-separator">•</span>
141
+ </template>
142
+ <template x-if="$store.fileBrowser.selectedCount > 0">
143
+ <span class="status-item">
144
+ Selected: <strong x-text="$store.fileBrowser.selectedCount"></strong>
145
+ </span>
146
+ </template>
147
+ </div>
148
+ </div>
149
+
150
+ <div x-show="$store.fileBrowser.selectedCount > 0 && !$store.fileBrowser.isPickerMode()" class="mass-action-toolbar file-mass-toolbar">
151
+ <div class="selection-info" x-text="$store.fileBrowser.selectedCountLabel"></div>
152
+
153
+ <div class="mass-actions">
154
+ <button
155
+ type="button"
156
+ class="btn btn-mass copy"
157
+ @click="$store.fileBrowser.copySelectedPaths()"
158
+ :disabled="$store.fileBrowser.isBulkBusy"
159
+ title="Copy Selected Paths"
160
+ >
161
+ <x-icon name="content_copy"></x-icon>
162
+ Copy Paths
163
+ </button>
164
+
165
+ <button
166
+ type="button"
167
+ class="btn btn-mass export"
168
+ @click="$store.fileBrowser.bulkDownloadFiles()"
169
+ :disabled="$store.fileBrowser.isBulkBusy"
170
+ title="Download Selected Items"
171
+ >
172
+ <x-icon name="folder_zip"></x-icon>
173
+ Download ZIP
174
+ </button>
175
+
176
+ <button
177
+ type="button"
178
+ class="btn btn-mass delete"
179
+ @click="$confirmClick($event, () => $store.fileBrowser.bulkDeleteFiles())"
180
+ :disabled="$store.fileBrowser.isBulkBusy"
181
+ title="Delete Selected Items"
182
+ >
183
+ <x-icon name="delete"></x-icon>
184
+ Delete
185
+ </button>
186
+
187
+ <button
188
+ type="button"
189
+ class="btn btn-mass clear"
190
+ @click="$store.fileBrowser.clearSelection()"
191
+ :disabled="$store.fileBrowser.isBulkBusy"
192
+ title="Clear Selection"
193
+ >
194
+ <x-icon name="close"></x-icon>
195
+ Clear
196
+ </button>
197
+ </div>
198
+ </div>
199
+
200
+ <!-- Files list -->
201
+ <div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">
202
+ <div class="file-header">
203
+ <div class="file-cell-select">
204
+ <input
205
+ type="checkbox"
206
+ :checked="$store.fileBrowser.allVisibleSelected"
207
+ :indeterminate="$store.fileBrowser.someVisibleSelected && !$store.fileBrowser.allVisibleSelected"
208
+ @change="$store.fileBrowser.toggleSelectAllVisible()"
209
+ :disabled="$store.fileBrowser.selectableEntries.length === 0"
210
+ title="Select visible items"
211
+ aria-label="Select visible items"
212
+ />
213
+ </div>
214
+ <div class="file-cell" @click="$store.fileBrowser.toggleSort('name')">Name <span x-show="$store.fileBrowser.browser.sortBy === 'name'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
215
+ <div class="file-cell-size" @click="$store.fileBrowser.toggleSort('size')">Size <span x-show="$store.fileBrowser.browser.sortBy === 'size'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
216
+ <div class="file-cell-date" @click="$store.fileBrowser.toggleSort('date')">Modified <span x-show="$store.fileBrowser.browser.sortBy === 'date'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
217
+ <div class="file-cell-actions"></div>
218
+ </div>
219
+
220
+ <!-- File list entries -->
221
+ <template x-if="$store.fileBrowser.visibleEntries.length">
222
+ <template x-for="file in $store.fileBrowser.visibleEntries" :key="file.path">
223
+ <div
224
+ class="file-item"
225
+ :data-is-dir="file.is_dir"
226
+ :draggable="!$store.fileBrowser.isPickerMode() && !$store.fileBrowser.isBulkBusy"
227
+ :class="{
228
+ 'selected': file.selected,
229
+ 'is-dragging': $store.fileBrowser.isDraggingPath(file.path),
230
+ 'is-drop-target': file.is_dir && $store.fileBrowser.dragOverPath === file.path
231
+ }"
232
+ @dragstart="$store.fileBrowser.startDrag(file, $event)"
233
+ @dragend="$store.fileBrowser.clearDragState()"
234
+ @dragover="file.is_dir && $store.fileBrowser.setDropTarget(file.path, $event)"
235
+ @dragleave="file.is_dir && $store.fileBrowser.clearDropTarget(file.path, $event)"
236
+ @drop="file.is_dir && $store.fileBrowser.dropItems(file.path, file.name, $event)"
237
+ >
238
+ <label class="file-select-cell" @click.stop>
239
+ <input
240
+ type="checkbox"
241
+ x-model="file.selected"
242
+ :disabled="!$store.fileBrowser.isSelectableEntry(file)"
243
+ :aria-label="`Select ${file.name}`"
244
+ />
245
+ </label>
246
+ <div class="file-name" @click="$store.fileBrowser.handleFileNameClick(file)">
247
+ <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" draggable="false" />
248
+ <span x-text="file.name"></span>
249
+ </div>
250
+ <div class="file-size" x-text="$store.fileBrowser.formatFileSize(file.size)"></div>
251
+ <div class="file-date" x-text="$store.fileBrowser.formatDate(file.modified)"></div>
252
+ <div class="file-actions" x-show="!$store.fileBrowser.isPickerMode()">
253
+ <button
254
+ type="button"
255
+ class="btn-icon-action file-editor-open-action"
256
+ x-show="$store.fileBrowser.isEditorSurface(file)"
257
+ @click.stop="$store.fileBrowser.openInSurface(file)"
258
+ :title="$store.fileBrowser.surfaceActionTitle(file)"
259
+ aria-label="Open in Editor"
260
+ >
261
+ <x-icon aria-hidden="true" :name="$store.fileBrowser.surfaceActionIcon(file)"></x-icon>
262
+ </button>
263
+
264
+ <!-- Secondary single-item actions (Rename/...) are grouped under a dropdown -->
265
+ <div
266
+ class="dropdown file-actions-dropdown"
267
+ @click.outside="$store.fileBrowser.closeDropdown()"
268
+ @keydown.escape.window="$store.fileBrowser.closeDropdown()"
269
+ >
270
+ <button
271
+ type="button"
272
+ class="btn-icon-action dropdown-trigger"
273
+ @click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"
274
+ :aria-expanded="$store.fileBrowser.isDropdownOpen(file.path).toString()"
275
+ aria-label="More actions"
276
+ title="More actions"
277
+ >
278
+ <x-icon name="more_vert"></x-icon>
279
+ </button>
280
+
281
+ <template x-teleport="body">
282
+ <div
283
+ class="dropdown-menu file-actions-menu"
284
+ x-show="$store.fileBrowser.isDropdownOpen(file.path)"
285
+ x-transition
286
+ :style="$store.fileBrowser.dropdownStyle"
287
+ style="display: none;"
288
+ @click.stop="$store.fileBrowser.closeDropdown()"
289
+ >
290
+ <button
291
+ type="button"
292
+ class="dropdown-item"
293
+ x-show="$store.fileBrowser.canOpenInActionMenu(file)"
294
+ @click="$store.fileBrowser.openInSurface(file)"
295
+ :title="$store.fileBrowser.surfaceActionTitle(file)"
296
+ >
297
+ <x-icon :name="$store.fileBrowser.surfaceActionIcon(file)"></x-icon>
298
+ <span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
299
+ </button>
300
+
301
+ <button
302
+ type="button"
303
+ class="dropdown-item"
304
+ x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
305
+ @click="$store.fileBrowser.openFileEditor(file)"
306
+ >
307
+ <x-icon name="file_open"></x-icon>
308
+ <span>Edit</span>
309
+ </button>
310
+
311
+ <button
312
+ type="button"
313
+ class="dropdown-item"
314
+ x-show="file.is_dir"
315
+ @click="$store.fileBrowser.downloadFile(file)"
316
+ >
317
+ <x-icon name="folder_zip"></x-icon>
318
+ <span>Download ZIP</span>
319
+ </button>
320
+
321
+ <button
322
+ type="button"
323
+ class="dropdown-item"
324
+ x-show="!file.is_dir && $store.fileBrowser.isArchive(file.name)"
325
+ @click="$store.fileBrowser.extractArchive(file)"
326
+ >
327
+ <x-icon name="unarchive"></x-icon>
328
+ <span>Extract</span>
329
+ </button>
330
+
331
+ <button
332
+ type="button"
333
+ class="dropdown-item"
334
+ @click="$store.fileBrowser.openRenameModal(file)"
335
+ >
336
+ <x-icon name="drive_file_rename_outline"></x-icon>
337
+ <span>Rename</span>
338
+ </button>
339
+ </div>
340
+ </template>
341
+ </div>
342
+ <button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
343
+ <x-icon name="download"></x-icon>
344
+ </button>
345
+ <button class="btn-icon-action danger" @click.stop="$confirmClick($event, () => $store.fileBrowser.deleteFile(file))" title="Delete item">
346
+ <x-icon name="delete"></x-icon>
347
+ </button>
348
+ </div>
349
+ </div>
350
+ </template>
351
+ </template>
352
+
353
+ <!-- Empty state -->
354
+ <template x-if="!$store.fileBrowser.browser.entries.length">
355
+ <div class="no-files">No files found</div>
356
+ </template>
357
+ <template x-if="$store.fileBrowser.browser.entries.length && !$store.fileBrowser.visibleEntries.length">
358
+ <div class="no-files">
359
+ <div>No matching files found</div>
360
+ <button class="btn btn-cancel btn-clear-search" @click="$store.fileBrowser.clearSearch()">
361
+ <x-icon name="close"></x-icon>
362
+ Clear
363
+ </button>
364
+ </div>
365
+ </template>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ </div>
371
+ </template>
372
+
373
+ <!-- Modal Footer (outside template x-if so it exists immediately) -->
374
+ <template x-if="$store.fileBrowser">
375
+ <div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
376
+ <div class="file-browser-picker-actions" x-show="$store.fileBrowser.isPickerMode()" style="display: none;">
377
+ <template x-if="$store.fileBrowser.isSaveAsPicker()">
378
+ <div class="picker-filename-field">
379
+ <x-icon aria-hidden="true" name="article"></x-icon>
380
+ <input
381
+ type="text"
382
+ class="picker-filename-input"
383
+ x-model="$store.fileBrowser.pickerFilename"
384
+ @input="$store.fileBrowser.onPickerFilenameInput()"
385
+ @keydown.enter.prevent="$store.fileBrowser.confirmPicker()"
386
+ placeholder="File name"
387
+ aria-label="File name"
388
+ spellcheck="false"
389
+ />
390
+ </div>
391
+ </template>
392
+ <span
393
+ class="picker-selection-label"
394
+ x-show="$store.fileBrowser.isTextOpenPicker()"
395
+ x-text="$store.fileBrowser.pickerSelectionLabel()"
396
+ ></span>
397
+ <template x-if="$store.fileBrowser.pickerFilenameError">
398
+ <span class="picker-inline-error" x-text="$store.fileBrowser.pickerFilenameError"></span>
399
+ </template>
400
+ <button
401
+ type="button"
402
+ class="btn btn-ok picker-confirm-button"
403
+ :disabled="!$store.fileBrowser.canConfirmPicker() || $store.fileBrowser.isBulkBusy"
404
+ @click="$store.fileBrowser.confirmPicker()"
405
+ x-text="$store.fileBrowser.pickerConfirmLabel"
406
+ ></button>
407
+ <button type="button" class="btn btn-cancel" :disabled="$store.fileBrowser.isBulkBusy" @click="$store.fileBrowser.cancelPicker()">Cancel</button>
408
+ </div>
409
+ <label class="btn btn-upload" x-show="!$store.fileBrowser.isPickerMode()">
410
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
411
+ Upload Files
412
+ <input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
413
+ </label>
414
+ <button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas' && !$store.fileBrowser.isPickerMode()" @click="$store.fileBrowser.handleClose()">Close Browser</button>
415
+ </div>
416
+ </template>
417
+ </div>
418
+
419
+ <style>
420
+ .file-browser-shell {
421
+ width: 100%;
422
+ min-width: 0;
423
+ }
424
+
425
+ .modal-inner.file-browser-modal {
426
+ box-sizing: border-box;
427
+ width: min(78vw, 1040px);
428
+ height: min(82vh, 820px);
429
+ min-width: min(420px, calc(100vw - 16px));
430
+ min-height: min(360px, calc(100vh - 16px));
431
+ max-width: calc(100vw - 16px);
432
+ max-height: calc(100vh - 16px);
433
+ resize: both;
434
+ overflow: hidden;
435
+ }
436
+
437
+ .modal-inner.file-browser-modal.is-focus-mode {
438
+ resize: none;
439
+ border-radius: 6px;
440
+ }
441
+
442
+ .modal-inner.file-browser-modal .modal-header {
443
+ min-height: 34px;
444
+ padding: 0.35rem 0.75rem 0.35rem 1rem;
445
+ background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
446
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
447
+ }
448
+
449
+ .modal-inner.file-browser-modal .modal-scroll,
450
+ .modal-inner.file-browser-modal .modal-bd.file-browser-modal-body {
451
+ display: flex;
452
+ flex: 1 1 auto;
453
+ width: 100%;
454
+ height: 100%;
455
+ min-width: 0;
456
+ min-height: 0;
457
+ max-height: none;
458
+ overflow: hidden;
459
+ padding: 0;
460
+ }
461
+
462
+ .modal-inner.file-browser-modal .modal-bd.file-browser-modal-body > div[x-data],
463
+ .modal-inner.file-browser-modal .file-browser-shell {
464
+ display: flex;
465
+ flex: 1 1 auto;
466
+ flex-direction: column;
467
+ height: 100%;
468
+ min-width: 0;
469
+ min-height: 0;
470
+ }
471
+
472
+ .file-browser-shell.is-surface {
473
+ display: flex;
474
+ flex: 1 1 auto;
475
+ flex-direction: column;
476
+ height: 100%;
477
+ min-height: 0;
478
+ }
479
+
480
+ /* File Browser Root */
481
+ .file-browser-root {
482
+ container: file-browser / inline-size;
483
+ display: flex;
484
+ flex-direction: column;
485
+ width: 100%;
486
+ height: 100%;
487
+ min-height: 400px;
488
+ }
489
+
490
+ .file-browser-root.is-surface {
491
+ flex: 1 1 auto;
492
+ min-height: 0;
493
+ }
494
+
495
+ .modal-inner.file-browser-modal .file-browser-root {
496
+ flex: 1 1 auto;
497
+ min-height: 0;
498
+ }
499
+
500
+ /* Loading State */
501
+ .loading-state {
502
+ display: flex;
503
+ flex-direction: column;
504
+ align-items: center;
505
+ justify-content: center;
506
+ padding: var(--spacing-lg);
507
+ min-height: 200px;
508
+ }
509
+
510
+ .loading-spinner {
511
+ width: 40px;
512
+ height: 40px;
513
+ border: 3px solid var(--color-border);
514
+ border-top: 3px solid var(--color-primary);
515
+ border-radius: 50%;
516
+ animation: spin 1s linear infinite;
517
+ margin-bottom: var(--spacing-md);
518
+ }
519
+
520
+ @keyframes spin {
521
+ 0% { transform: rotate(0deg); }
522
+ 100% { transform: rotate(360deg); }
523
+ }
524
+
525
+ .loading-state p {
526
+ color: var(--color-text-secondary);
527
+ margin: 0;
528
+ }
529
+
530
+ /* File Browser Content */
531
+ .file-browser-content {
532
+ display: flex;
533
+ flex-direction: column;
534
+ padding: var(--spacing-sm) var(--spacing-sm);
535
+ gap: var(--spacing-sm);
536
+ min-height: 0;
537
+ }
538
+
539
+ .file-browser-shell.is-surface .file-browser-content {
540
+ flex: 1 1 auto;
541
+ overflow: hidden;
542
+ }
543
+
544
+ .modal-inner.file-browser-modal .file-browser-content {
545
+ flex: 1 1 auto;
546
+ overflow: hidden;
547
+ }
548
+
549
+ /* File Browser Styles */
550
+ .files-list {
551
+ width: 100%;
552
+ border-radius: 4px;
553
+ }
554
+
555
+ .file-browser-shell.is-surface .files-list {
556
+ flex: 1 1 auto;
557
+ min-height: 0;
558
+ overflow: auto;
559
+ border: 1px solid var(--color-border);
560
+ background: color-mix(in srgb, var(--color-panel) 82%, transparent);
561
+ }
562
+
563
+ .modal-inner.file-browser-modal .files-list {
564
+ flex: 1 1 auto;
565
+ min-height: 0;
566
+ overflow: auto;
567
+ border: 1px solid var(--color-border);
568
+ background: color-mix(in srgb, var(--color-panel) 82%, transparent);
569
+ }
570
+
571
+ .file-browser-shell.is-surface .file-header {
572
+ position: sticky;
573
+ top: 0;
574
+ z-index: 2;
575
+ }
576
+
577
+ .modal-inner.file-browser-modal .file-header {
578
+ position: sticky;
579
+ top: 0;
580
+ z-index: 2;
581
+ }
582
+
583
+ /* Header Styles */
584
+ .file-header {
585
+ width: 100%;
586
+ border-radius: 4px;
587
+ overflow: hidden;
588
+ display: grid;
589
+ grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
590
+ background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
591
+ padding: 8px 0;
592
+ font-weight: bold;
593
+ border-bottom: 1px solid var(--color-border);
594
+ color: var(--color-primary);
595
+ }
596
+ .file-cell-select,
597
+ .file-cell-actions {
598
+ display: flex;
599
+ align-items: center;
600
+ justify-content: center;
601
+ }
602
+ .file-cell,
603
+ .file-cell-size,
604
+ .file-cell-date {
605
+ color: var(--color-primary);
606
+ padding: 4px;
607
+ cursor: pointer;
608
+ }
609
+
610
+ /* File Item Styles */
611
+ .file-item {
612
+ display: grid;
613
+ grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
614
+ align-items: center;
615
+ padding: 8px 0;
616
+ font-size: 0.875rem;
617
+ border-top: 1px solid var(--color-border);
618
+ transition: background-color 120ms cubic-bezier(0.2, 0, 0, 1), box-shadow 120ms cubic-bezier(0.2, 0, 0, 1), opacity 120ms ease, transform 120ms cubic-bezier(0.2, 0, 0, 1);
619
+ white-space: nowrap;
620
+ border-radius: 4px;
621
+ overflow: visible; /* allow action dropdown menus to overflow the row */
622
+ color: var(--color-text);
623
+ }
624
+ .file-item:hover {
625
+ background-color: var(--color-secondary);
626
+ }
627
+ .file-item.selected {
628
+ background: color-mix(in srgb, var(--color-primary) 12%, transparent);
629
+ }
630
+ .file-item.is-dragging {
631
+ opacity: 0.45;
632
+ transform: scale(0.995);
633
+ }
634
+ .file-item.is-drop-target,
635
+ .nav-button.is-drop-target {
636
+ background: color-mix(in srgb, var(--color-primary) 18%, var(--color-input) 82%);
637
+ box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--color-primary) 72%, transparent);
638
+ }
639
+ .file-item.is-drop-target .file-icon,
640
+ .nav-button.is-drop-target .material-symbols-outlined {
641
+ transform: scale(1.12);
642
+ }
643
+
644
+ .file-select-cell {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ cursor: pointer;
649
+ min-height: 1.75rem;
650
+ }
651
+
652
+ .file-cell-select input,
653
+ .file-select-cell input {
654
+ width: 1rem;
655
+ height: 1rem;
656
+ accent-color: var(--color-primary);
657
+ cursor: pointer;
658
+ }
659
+
660
+ /* File Icon and Name */
661
+ .file-icon {
662
+ width: 1.8rem;
663
+ height: 1.8rem;
664
+ margin: 0 1rem 0 0.7rem;
665
+ vertical-align: middle;
666
+ font-size: var(--font-size-sm);
667
+ transition: transform 120ms cubic-bezier(0.2, 0, 0, 1);
668
+ }
669
+ .file-name {
670
+ display: flex;
671
+ align-items: center;
672
+ font-weight: 500;
673
+ margin-right: var(--spacing-sm);
674
+ overflow: hidden;
675
+ }
676
+ .file-name > span {
677
+ white-space: nowrap;
678
+ overflow: hidden;
679
+ text-overflow: ellipsis;
680
+ }
681
+ .file-size,
682
+ .file-date {
683
+ color: var(--color-text-secondary);
684
+ }
685
+ /* No Files Message */
686
+ .no-files {
687
+ padding: 32px;
688
+ text-align: center;
689
+ color: var(--color-text-secondary);
690
+ }
691
+ .no-files .btn-clear-search {
692
+ display: inline-flex;
693
+ align-items: center;
694
+ gap: 0.25rem;
695
+ margin-top: 0.75rem;
696
+ }
697
+ /* Light Mode Adjustments */
698
+ .light-mode .file-item:hover {
699
+ background-color: var(--color-secondary-light);
700
+ }
701
+
702
+ /* Path Navigator Styles */
703
+ .path-navigator-wrap {
704
+ display: flex;
705
+ flex-direction: column;
706
+ gap: 0.35rem;
707
+ }
708
+
709
+ .path-navigator {
710
+ overflow: hidden;
711
+ display: flex;
712
+ align-items: center;
713
+ gap: 0.75rem;
714
+ background-color: var(--color-message-bg);
715
+ padding: 0.5rem var(--spacing-sm);
716
+ margin: 0;
717
+ border: 1px solid var(--color-border);
718
+ border-radius: 8px;
719
+ }
720
+
721
+ .path-navigator .back-button {
722
+ flex: 0 0 auto;
723
+ }
724
+
725
+ .path-input-shell {
726
+ position: relative;
727
+ display: flex;
728
+ align-items: center;
729
+ flex: 1;
730
+ min-width: 0;
731
+ }
732
+
733
+ .path-input {
734
+ width: 100%;
735
+ height: 2.25rem;
736
+ min-width: 0;
737
+ border: 1px solid transparent;
738
+ border-radius: 6px;
739
+ background: color-mix(in srgb, var(--color-input) 78%, transparent);
740
+ color: var(--color-text);
741
+ padding: 0 2.5rem 0 0.75rem;
742
+ font: inherit;
743
+ font-family: 'Roboto Mono', monospace;
744
+ -webkit-font-optical-sizing: auto;
745
+ font-optical-sizing: auto;
746
+ line-height: 1;
747
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
748
+ }
749
+
750
+ .path-input:focus {
751
+ outline: none;
752
+ border-color: var(--color-primary);
753
+ background: var(--color-input-focus);
754
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
755
+ }
756
+
757
+ .path-input-shell.has-error .path-input {
758
+ border-color: var(--color-error);
759
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error) 18%, transparent);
760
+ }
761
+
762
+ .path-submit {
763
+ position: absolute;
764
+ right: 0.35rem;
765
+ width: 1.75rem;
766
+ height: 1.75rem;
767
+ color: var(--color-text);
768
+ }
769
+
770
+ .path-submit:disabled {
771
+ opacity: 0.5;
772
+ cursor: wait;
773
+ }
774
+
775
+ .path-error {
776
+ color: var(--color-error);
777
+ font-size: 0.8rem;
778
+ padding: 0 0.25rem;
779
+ }
780
+
781
+ .file-browser-toolbar {
782
+ display: flex;
783
+ align-items: center;
784
+ justify-content: space-between;
785
+ gap: var(--spacing-sm);
786
+ }
787
+
788
+ .file-search-shell {
789
+ position: relative;
790
+ display: flex;
791
+ align-items: center;
792
+ min-width: 16rem;
793
+ flex: 1;
794
+ }
795
+
796
+ .file-search-icon {
797
+ position: absolute;
798
+ left: 0.65rem;
799
+ font-size: 1.1rem;
800
+ color: var(--color-primary);
801
+ opacity: 0.72;
802
+ pointer-events: none;
803
+ }
804
+
805
+ .file-search-input {
806
+ width: 100%;
807
+ height: 2.4rem;
808
+ border: 1px solid var(--color-border);
809
+ border-radius: 6px;
810
+ background: var(--color-input);
811
+ color: var(--color-text);
812
+ padding: 0 2.35rem 0 2.2rem;
813
+ font: inherit;
814
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
815
+ }
816
+
817
+ .file-search-input:focus {
818
+ outline: none;
819
+ border-color: var(--color-primary);
820
+ background: var(--color-input-focus);
821
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
822
+ }
823
+
824
+ .file-search-clear {
825
+ position: absolute;
826
+ right: 0.35rem;
827
+ width: 1.65rem;
828
+ height: 1.65rem;
829
+ color: var(--color-text);
830
+ }
831
+
832
+ .new-item-buttons {
833
+ display: flex;
834
+ justify-content: flex-end;
835
+ gap: 0.5em;
836
+ }
837
+ .new-item-buttons button {
838
+ margin-left: 0;
839
+ }
840
+
841
+ .file-status-bar {
842
+ border-radius: 8px 8px 0 0;
843
+ margin-bottom: calc(-1 * var(--spacing-sm));
844
+ }
845
+
846
+ .file-mass-toolbar {
847
+ border-left: 1px solid var(--color-border);
848
+ border-right: 1px solid var(--color-border);
849
+ border-bottom: 1px solid var(--color-border);
850
+ }
851
+
852
+ .file-mass-toolbar .btn-mass:disabled {
853
+ opacity: 0.55;
854
+ cursor: wait;
855
+ }
856
+
857
+ .nav-button {
858
+ appearance: none;
859
+ display: inline-flex;
860
+ align-items: center;
861
+ justify-content: center;
862
+ gap: 0.1rem;
863
+ min-height: 2.35rem;
864
+ padding: 0.22rem 0.62rem 0.24rem;
865
+ border: 1px solid var(--color-border);
866
+ border-radius: 6px;
867
+ background: color-mix(in srgb, var(--color-input) 86%, var(--color-panel) 14%);
868
+ color: var(--color-text);
869
+ font: inherit;
870
+ line-height: 1;
871
+ box-shadow: none;
872
+ cursor: pointer;
873
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
874
+ }
875
+ .nav-button.back-button {
876
+ flex: 0 0 auto;
877
+ flex-direction: column;
878
+ min-width: 3.1rem;
879
+ }
880
+ .nav-button .material-symbols-outlined {
881
+ font-size: 1.05rem;
882
+ line-height: 1;
883
+ transition: transform 120ms cubic-bezier(0.2, 0, 0, 1);
884
+ }
885
+ .nav-button-label {
886
+ font-size: 0.66rem;
887
+ font-weight: 600;
888
+ letter-spacing: 0;
889
+ line-height: 1;
890
+ }
891
+ .nav-button:hover:not(:disabled) {
892
+ border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
893
+ background: color-mix(in srgb, var(--color-input-focus) 84%, var(--color-primary) 16%);
894
+ color: var(--color-text);
895
+ }
896
+ .nav-button:focus-visible {
897
+ outline: none;
898
+ border-color: var(--color-primary);
899
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 22%, transparent);
900
+ }
901
+ .nav-button:disabled {
902
+ cursor: not-allowed;
903
+ opacity: 0.5;
904
+ }
905
+ /* Folder Specific Styles */
906
+ .file-item[data-is-dir="true"] {
907
+ cursor: pointer;
908
+ }
909
+ .file-item[draggable="true"] {
910
+ cursor: grab;
911
+ }
912
+ .file-item[draggable="true"]:active {
913
+ cursor: grabbing;
914
+ }
915
+ .file-item[data-is-dir="true"]:hover {
916
+ background-color: var(--color-secondary);
917
+ }
918
+ .file-item[data-is-dir="true"].is-drop-target:hover {
919
+ background: color-mix(in srgb, var(--color-primary) 18%, var(--color-input) 82%);
920
+ }
921
+
922
+ /* Upload Button Styles */
923
+ .btn-upload {
924
+ display: inline-flex;
925
+ align-items: center;
926
+ padding: 8px 16px;
927
+ background: #4248f1;
928
+ gap: 0.5rem;
929
+ color: white;
930
+ border-radius: 4px;
931
+ cursor: pointer;
932
+ transition: background-color 0.3s ease-in-out;
933
+ }
934
+ .btn-upload > svg {
935
+ width: 20px;
936
+ }
937
+ .btn-upload:hover {
938
+ background-color: #353bc5;
939
+ }
940
+ .btn-upload:active {
941
+ background-color: #2b309c;
942
+ }
943
+
944
+ .file-browser-footer.is-surface {
945
+ display: flex;
946
+ flex: 0 0 auto;
947
+ justify-content: flex-end;
948
+ gap: var(--spacing-sm);
949
+ padding: var(--spacing-sm);
950
+ border-top: 1px solid var(--color-border);
951
+ background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
952
+ }
953
+
954
+ .file-browser-picker-actions {
955
+ display: flex;
956
+ flex: 1 1 auto;
957
+ align-items: center;
958
+ justify-content: flex-end;
959
+ gap: var(--spacing-sm);
960
+ min-width: 0;
961
+ }
962
+
963
+ .picker-filename-field {
964
+ display: flex;
965
+ align-items: center;
966
+ flex: 1 1 16rem;
967
+ max-width: 24rem;
968
+ min-width: 10rem;
969
+ height: 2.35rem;
970
+ border: 1px solid var(--color-border);
971
+ border-radius: 7px;
972
+ background: var(--color-input);
973
+ color: var(--color-text);
974
+ }
975
+
976
+ .picker-filename-field .material-symbols-outlined {
977
+ flex: 0 0 auto;
978
+ padding-left: 0.65rem;
979
+ color: var(--color-primary);
980
+ font-size: 1.1rem;
981
+ opacity: 0.78;
982
+ }
983
+
984
+ .picker-filename-input {
985
+ flex: 1 1 auto;
986
+ width: 100%;
987
+ min-width: 0;
988
+ height: 100%;
989
+ border: 0;
990
+ outline: 0;
991
+ background: transparent;
992
+ color: var(--color-text);
993
+ padding: 0 0.7rem;
994
+ font: inherit;
995
+ }
996
+
997
+ .picker-selection-label,
998
+ .picker-inline-error {
999
+ overflow: hidden;
1000
+ text-overflow: ellipsis;
1001
+ white-space: nowrap;
1002
+ font-size: 0.82rem;
1003
+ font-weight: 700;
1004
+ letter-spacing: 0;
1005
+ }
1006
+
1007
+ .picker-selection-label {
1008
+ color: var(--color-text-secondary);
1009
+ }
1010
+
1011
+ .picker-inline-error {
1012
+ color: var(--color-error);
1013
+ }
1014
+
1015
+ .picker-confirm-button {
1016
+ min-width: 7.6rem;
1017
+ }
1018
+
1019
+ /* File Actions */
1020
+ .file-actions {
1021
+ display: flex;
1022
+ gap: var(--spacing-xs);
1023
+ justify-content: flex-end;
1024
+ padding-right: 0.5rem;
1025
+ }
1026
+ .file-editor-open-action {
1027
+ border-color: color-mix(in srgb, var(--color-primary) 52%, var(--color-border));
1028
+ background: color-mix(in srgb, var(--color-primary) 10%, transparent);
1029
+ }
1030
+ .file-actions-menu {
1031
+ margin: 0;
1032
+ }
1033
+ .btn-new-item {
1034
+ display: inline-flex;
1035
+ align-items: center;
1036
+ justify-content: center;
1037
+ gap: 0.4rem;
1038
+ width: 2.8rem;
1039
+ height: 2.8rem;
1040
+ min-width: 2.8rem;
1041
+ padding: 0;
1042
+ border-radius: 8px;
1043
+ }
1044
+ .btn-new-item .material-symbols-outlined {
1045
+ font-size: 1.35rem;
1046
+ line-height: 1;
1047
+ }
1048
+ .btn-secondary {
1049
+ background: var(--color-secondary);
1050
+ color: var(--color-text);
1051
+ border: 1px solid var(--color-border);
1052
+ }
1053
+ .btn-secondary:hover {
1054
+ filter: brightness(1.05);
1055
+ }
1056
+ .btn-secondary:active {
1057
+ filter: brightness(0.95);
1058
+ }
1059
+ /* Responsive Design */
1060
+ @media (max-width: 768px) {
1061
+ .path-navigator {
1062
+ align-items: center;
1063
+ flex-direction: row;
1064
+ gap: 0.5rem;
1065
+ }
1066
+ .path-navigator .back-button {
1067
+ align-self: stretch;
1068
+ }
1069
+ .file-browser-toolbar {
1070
+ align-items: center;
1071
+ flex-direction: row;
1072
+ gap: 0.5rem;
1073
+ }
1074
+ .file-search-shell {
1075
+ flex: 1 1 auto;
1076
+ min-width: 0;
1077
+ width: auto;
1078
+ }
1079
+ .new-item-buttons {
1080
+ flex: 0 0 auto;
1081
+ gap: 0.4rem;
1082
+ }
1083
+ .btn-new-item {
1084
+ width: 2.5rem;
1085
+ min-width: 2.5rem;
1086
+ height: 2.5rem;
1087
+ }
1088
+ .file-header {
1089
+ grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 8.75rem;
1090
+ }
1091
+ .file-item {
1092
+ grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.5fr) 8.75rem;
1093
+ }
1094
+ .file-cell-date,
1095
+ .file-date {
1096
+ display: none;
1097
+ }
1098
+ .file-actions {
1099
+ gap: 0.2rem;
1100
+ padding-right: 0.2rem;
1101
+ }
1102
+ }
1103
+ @media (max-width: 540px) {
1104
+ .path-navigator .back-button {
1105
+ min-width: 2.45rem;
1106
+ padding-inline: 0.5rem;
1107
+ }
1108
+ .path-navigator .nav-button-label {
1109
+ display: none;
1110
+ }
1111
+ .file-header,
1112
+ .file-item {
1113
+ grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;
1114
+ }
1115
+ .file-cell-date,
1116
+ .file-date {
1117
+ display: none;
1118
+ }
1119
+ }
1120
+ @container file-browser (max-width: 620px) {
1121
+ .file-header,
1122
+ .file-item {
1123
+ grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;
1124
+ }
1125
+ .file-cell-date,
1126
+ .file-date {
1127
+ display: none;
1128
+ }
1129
+ .file-actions {
1130
+ gap: 0.2rem;
1131
+ padding-right: 0.2rem;
1132
+ }
1133
+ .file-browser-picker-actions {
1134
+ flex-wrap: wrap;
1135
+ justify-content: stretch;
1136
+ }
1137
+ .picker-filename-field {
1138
+ flex-basis: 100%;
1139
+ max-width: none;
1140
+ }
1141
+ .picker-confirm-button {
1142
+ flex: 1 1 auto;
1143
+ }
1144
+ }
1145
+ @media (prefers-reduced-motion: reduce) {
1146
+ .file-item,
1147
+ .file-icon,
1148
+ .nav-button .material-symbols-outlined {
1149
+ transition: none;
1150
+ }
1151
+ }
1152
+ </style>
1153
+ </body>
1154
+ </html>
webui/components/modals/file-browser/rename-modal.html CHANGED
@@ -1,3 +1,86 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f179f6ea4cd870eec0b43db83ca41247a2f6fd3c24631a6fbd9f3e1f6c11eb8a
3
- size 2643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <title>Rename</title>
4
+ <script type="module">
5
+ import { store } from "/components/modals/file-browser/file-browser-store.js";
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <div x-data>
10
+ <template x-if="$store.fileBrowser">
11
+ <div class="rename-modal-root" x-init="$nextTick(() => $refs.renameInput?.focus())">
12
+ <div class="rename-form">
13
+ <label class="rename-label" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'New folder name' : 'New name'"></label>
14
+ <input
15
+ class="rename-input"
16
+ type="text"
17
+ x-ref="renameInput"
18
+ x-model="$store.fileBrowser.renameName"
19
+ @keydown.enter.prevent="$store.fileBrowser.confirmRename()"
20
+ :disabled="$store.fileBrowser.isRenaming"
21
+ />
22
+ <template x-if="$store.fileBrowser.renameError">
23
+ <p class="rename-error" x-text="$store.fileBrowser.renameError"></p>
24
+ </template>
25
+ <p class="rename-hint" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'Folder will be created in the current directory.' : 'Renaming will keep the item in the same directory.'"></p>
26
+ </div>
27
+ </div>
28
+ </template>
29
+ </div>
30
+
31
+ <template x-if="$store.fileBrowser">
32
+ <div class="modal-footer" data-modal-footer>
33
+ <button class="btn btn-ok" @click="$store.fileBrowser.confirmRename()" :disabled="$store.fileBrowser.isRenaming || !$store.fileBrowser.renameName.trim()" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'Create Folder' : 'Rename'"></button>
34
+ <button class="btn btn-cancel" @click="$store.fileBrowser.closeRenameModal()" :disabled="$store.fileBrowser.isRenaming">Cancel</button>
35
+ </div>
36
+ </template>
37
+
38
+ <style>
39
+ .rename-modal-root {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: var(--spacing-md);
43
+ width: 100%;
44
+ }
45
+
46
+ .rename-form {
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: var(--spacing-sm);
50
+ margin: 1rem;
51
+ }
52
+
53
+ .rename-label {
54
+ font-weight: 600;
55
+ color: var(--color-text);
56
+ }
57
+
58
+ .rename-input {
59
+ width: 100%;
60
+ padding: 10px 12px;
61
+ border-radius: 6px;
62
+ border: 1px solid var(--color-border);
63
+ background: var(--color-background);
64
+ color: var(--color-text);
65
+ }
66
+
67
+ .rename-input:disabled {
68
+ opacity: 0.7;
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ .rename-hint {
73
+ margin: 0;
74
+ color: var(--color-text-secondary);
75
+ font-size: 0.85rem;
76
+ }
77
+
78
+ .rename-error {
79
+ margin: 0;
80
+ color: var(--color-accent);
81
+ font-weight: 500;
82
+ font-size: 0.85rem;
83
+ }
84
+ </style>
85
+ </body>
86
+ </html>
webui/components/modals/file-editor/file-edit-modal.html CHANGED
@@ -1,3 +1,162 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:897f2a7f036db3a0e039101dab24ece725dac8ca04c66ed5700822735fc74a84
3
- size 4261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <title>File Editor</title>
4
+ <script type="module">
5
+ import { store } from "/components/modals/file-editor/file-editor-store.js";
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <div x-data>
10
+ <template x-if="$store.fileEditor">
11
+ <div class="file-edit-modal-root">
12
+ <template x-if="$store.fileEditor.isEditLoading">
13
+ <div class="loading-state">
14
+ <div class="loading-spinner"></div>
15
+ <p>Loading file…</p>
16
+ </div>
17
+ </template>
18
+
19
+ <template x-if="$store.fileEditor.editError">
20
+ <div class="error-state">
21
+ <p class="error-message" x-text="$store.fileEditor.editError"></p>
22
+ </div>
23
+ </template>
24
+
25
+ <template x-if="!$store.fileEditor.isEditLoading && !$store.fileEditor.editError">
26
+ <div class="file-edit-content">
27
+ <div class="file-edit-meta">
28
+ <div class="file-edit-field">
29
+ <label>File name</label>
30
+ <input
31
+ type="text"
32
+ x-model="$store.fileEditor.editFileName"
33
+ @input="$store.fileEditor.updateEditorMode()"
34
+ :disabled="!$store.fileEditor.editIsNew"
35
+ />
36
+ <template x-if="$store.fileEditor.editSaveError">
37
+ <p class="file-edit-inline-error" x-text="$store.fileEditor.editSaveError"></p>
38
+ </template>
39
+ </div>
40
+ <div class="file-edit-path" x-text="$store.fileEditor.editPathLabel()"></div>
41
+ </div>
42
+ <div id="file-editor-container" class="no-scrollbar"></div>
43
+ </div>
44
+ </template>
45
+ </div>
46
+ </template>
47
+ </div>
48
+
49
+ <template x-if="$store.fileEditor">
50
+ <div class="modal-footer" data-modal-footer>
51
+ <button
52
+ class="btn btn-ok"
53
+ @click="$store.fileEditor.saveFileEdits()"
54
+ :disabled="$store.fileEditor.isSaving || $store.fileEditor.isEditLoading || $store.fileEditor.editError"
55
+ x-text="$store.fileEditor.isSaving ? 'Saving…' : 'Save'"
56
+ ></button>
57
+ <button class="btn btn-cancel" @click="$store.fileEditor.closeFileEditor()" :disabled="$store.fileEditor.isSaving">Cancel</button>
58
+ </div>
59
+ </template>
60
+
61
+ <style>
62
+ .file-edit-modal-root {
63
+ display: flex;
64
+ flex-direction: column;
65
+ width: 100%;
66
+ min-height: 400px;
67
+ }
68
+
69
+ .file-edit-content {
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: var(--spacing-md);
73
+ margin: 1rem;
74
+ }
75
+
76
+ .file-edit-meta {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: var(--spacing-xs);
80
+ }
81
+
82
+ .file-edit-field {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 6px;
86
+ }
87
+
88
+ .file-edit-field label {
89
+ font-weight: 600;
90
+ color: var(--color-text);
91
+ }
92
+
93
+ .file-edit-field input {
94
+ padding: 8px 12px;
95
+ border-radius: 6px;
96
+ border: 1px solid var(--color-border);
97
+ background: var(--color-background);
98
+ color: var(--color-text);
99
+ }
100
+
101
+ .file-edit-field input:disabled {
102
+ opacity: 0.7;
103
+ cursor: not-allowed;
104
+ }
105
+
106
+ .file-edit-inline-error {
107
+ margin: 0;
108
+ color: var(--color-error);
109
+ font-weight: 500;
110
+ font-size: 0.85rem;
111
+ }
112
+
113
+ .file-edit-path {
114
+ font-size: 0.85rem;
115
+ color: var(--color-text-secondary);
116
+ word-break: break-all;
117
+ }
118
+
119
+ #file-editor-container {
120
+ width: 100%;
121
+ min-height: 55vh;
122
+ border-radius: 0.4rem;
123
+ overflow: auto;
124
+ }
125
+
126
+ .ace_scrollbar-v {
127
+ overflow-y: auto;
128
+ }
129
+
130
+ .loading-state,
131
+ .error-state {
132
+ display: flex;
133
+ flex-direction: column;
134
+ align-items: center;
135
+ justify-content: center;
136
+ padding: var(--spacing-lg);
137
+ min-height: 200px;
138
+ }
139
+
140
+ .loading-spinner {
141
+ width: 40px;
142
+ height: 40px;
143
+ border: 3px solid var(--color-border);
144
+ border-top: 3px solid var(--color-primary);
145
+ border-radius: 50%;
146
+ animation: spin 1s linear infinite;
147
+ margin-bottom: var(--spacing-md);
148
+ }
149
+
150
+ @keyframes spin {
151
+ 0% { transform: rotate(0deg); }
152
+ 100% { transform: rotate(360deg); }
153
+ }
154
+
155
+ .error-message {
156
+ color: var(--color-error);
157
+ font-weight: 500;
158
+ margin: 0;
159
+ }
160
+ </style>
161
+ </body>
162
+ </html>