Spaces:
Running
Running
Upload folder using huggingface_hub (part 10)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- tools/unknown.py.dox.md +48 -3
- tools/vision_load.py +233 -3
- tools/vision_load.py.dox.md +62 -3
- tools/wait.py +89 -3
- tools/wait.py.dox.md +54 -3
- update_reqs.py +38 -3
- usr/skills/.gitkeep +1 -3
- webui/AGENTS.md +60 -3
- webui/components/AGENTS.md +77 -3
- webui/components/_examples/AGENTS.md +27 -3
- webui/components/_examples/_example-component.html +36 -3
- webui/components/_examples/_example-store.js +23 -3
- webui/components/canvas/AGENTS.md +33 -3
- webui/components/canvas/right-canvas-store.js +620 -3
- webui/components/canvas/right-canvas.css +384 -3
- webui/components/canvas/right-canvas.html +132 -3
- webui/components/chat/AGENTS.md +47 -3
- webui/components/chat/attachments/attachmentsStore.js +511 -3
- webui/components/chat/attachments/dragDropOverlay.html +28 -3
- webui/components/chat/attachments/inputPreview.html +30 -3
- webui/components/chat/input/bottom-actions-bar.html +98 -3
- webui/components/chat/input/bottom-actions.html +161 -3
- webui/components/chat/input/chat-bar-input.html +457 -3
- webui/components/chat/input/chat-bar.html +52 -3
- webui/components/chat/input/composer-banner-store.js +69 -3
- webui/components/chat/input/input-store.js +780 -3
- webui/components/chat/input/progress.html +57 -3
- webui/components/chat/message-queue/message-queue-store.js +245 -3
- webui/components/chat/message-queue/message-queue.html +245 -3
- webui/components/chat/model-gate-store.js +233 -3
- webui/components/chat/model-setup-gate.html +203 -3
- webui/components/chat/navigation/chat-navigation-store.js +127 -3
- webui/components/chat/top-section/chat-top-store.js +13 -3
- webui/components/chat/top-section/chat-top.html +42 -3
- webui/components/dropdown/AGENTS.md +26 -3
- webui/components/dropdown/dropdown.html +69 -3
- webui/components/messages/AGENTS.md +38 -3
- webui/components/messages/action-buttons/simple-action-buttons.css +153 -3
- webui/components/messages/action-buttons/simple-action-buttons.js +134 -3
- webui/components/messages/process-group/process-group-dom.js +76 -3
- webui/components/messages/process-group/process-group.css +914 -3
- webui/components/messages/resize/message-resize-store.js +137 -3
- webui/components/modals/AGENTS.md +34 -3
- webui/components/modals/context/context-store.js +132 -3
- webui/components/modals/context/context.html +103 -3
- webui/components/modals/file-browser/AGENTS.md +42 -3
- webui/components/modals/file-browser/file-browser-store.js +1599 -3
- webui/components/modals/file-browser/file-browser.html +1154 -3
- webui/components/modals/file-browser/rename-modal.html +86 -3
- webui/components/modals/file-editor/file-edit-modal.html +162 -3
tools/unknown.py.dox.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
oid sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
|
| 3 |
-
size 1
|
|
|
|
| 1 |
+
|
|
|
|
|
|
webui/AGENTS.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">×</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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "&")
|
| 17 |
+
.replace(/</g, "<")
|
| 18 |
+
.replace(/>/g, ">")
|
| 19 |
+
.replace(/"/g, """)
|
| 20 |
+
.replace(/'/g, "'");
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|