Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d11e3316 | ||
|
|
341f093b25 | ||
|
|
76500c8b73 | ||
|
|
b80b52aa46 | ||
|
|
8b209e0dd7 | ||
|
|
a569226f88 | ||
|
|
4e7b0389ea | ||
|
|
c1945d410b | ||
|
|
b6c11a35ac | ||
|
|
5601f24449 | ||
|
|
bd7e480236 | ||
|
|
c81afd6e50 | ||
|
|
5f1991bf6b | ||
|
|
2b6897f982 | ||
|
|
58033baba2 | ||
|
|
c346f018d4 | ||
|
|
a4c9b069dc | ||
|
|
e0e7cfa673 | ||
|
|
170959d80a | ||
|
|
91f87137b6 | ||
|
|
7a5a6ef99b | ||
|
|
da4a28ec6d | ||
|
|
587e76fbc2 | ||
|
|
1f89f3102f | ||
|
|
5c03e27ce2 | ||
|
|
6020b9f4fe | ||
|
|
3b15afafa2 | ||
|
|
b6f749af94 | ||
|
|
5e6a0d9eea | ||
|
|
2106f63723 | ||
|
|
fcdc10a0f3 | ||
|
|
a61a0bc019 | ||
|
|
f8630a1456 | ||
|
|
647520f83e | ||
|
|
c29ab7b9fe | ||
|
|
f58e4622cf | ||
|
|
e8b7ce8c19 | ||
|
|
d7993ab178 | ||
|
|
a1d6654264 | ||
|
|
a55523fd6d | ||
|
|
9166d727b8 | ||
|
|
5e65f6d79f | ||
|
|
6efec39ecf | ||
|
|
6a8e7069b4 | ||
|
|
09b6d22dfe | ||
|
|
08015c3a8f | ||
|
|
fe1ab949fd | ||
|
|
704bbcca80 | ||
|
|
868a2f7d17 | ||
|
|
1600008ab0 | ||
|
|
1d48863b85 | ||
|
|
678b86df1c | ||
|
|
07be37d996 | ||
|
|
f3ec79964e | ||
|
|
2388e0687b | ||
|
|
1011cd24e2 | ||
|
|
7c954969b7 | ||
|
|
306e2d2318 | ||
|
|
8662254ab2 | ||
|
|
a7ef17da7a | ||
|
|
31dcd68bfa | ||
|
|
771571aee4 | ||
|
|
9df5f879b4 | ||
|
|
a52393a3b6 | ||
|
|
9be941dac1 | ||
|
|
7af59f474d | ||
|
|
bcd7e2ce89 | ||
|
|
c2a3b9ce58 | ||
|
|
779c0dd80d | ||
|
|
8fa8aabbbb | ||
|
|
1b059d1ae7 | ||
|
|
0ab90040ad | ||
|
|
b5aef05e2c | ||
|
|
c18edf4ac0 | ||
|
|
305e26558e | ||
|
|
092a97ef75 | ||
|
|
db488df1c3 | ||
|
|
798e602a8e | ||
|
|
ec926ce89e | ||
|
|
7dc21f08a1 | ||
|
|
5f37c9e85c | ||
|
|
8bd4a419de | ||
|
|
d14bf23fa3 | ||
|
|
a57b33782e | ||
|
|
aff205dcc2 | ||
|
|
369d0eeeff | ||
|
|
10fbade64b | ||
|
|
7f74b324c3 | ||
|
|
0f922002eb | ||
|
|
0f398f8e9c | ||
|
|
2afbe77763 | ||
|
|
860a3f67bb | ||
|
|
eae1d7d147 | ||
|
|
ac4f596ca2 | ||
|
|
e6fea77d14 | ||
|
|
f1379bd6c2 | ||
|
|
aae35c5ee4 | ||
|
|
63a9bde77b | ||
|
|
c388daa665 | ||
|
|
7ed7170960 | ||
|
|
aefb36299a | ||
|
|
99ff67eb03 |
+11
@@ -68,6 +68,17 @@ environments/benchmarks/evals/
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
|
||||
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
|
||||
# resolves the stale .js OVER the .tsx — never track these)
|
||||
apps/desktop/src/**/*.js
|
||||
apps/desktop/src/**/*.js.map
|
||||
apps/desktop/src/**/*.d.ts
|
||||
!apps/desktop/src/global.d.ts
|
||||
!apps/desktop/src/vite-env.d.ts
|
||||
apps/shared/src/**/*.js
|
||||
apps/shared/src/**/*.js.map
|
||||
apps/shared/src/**/*.d.ts
|
||||
apps/desktop/release/
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
+112
-7
@@ -44,6 +44,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
@@ -102,7 +103,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
|
||||
from agent.process_bootstrap import build_keepalive_http_client
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
@@ -156,22 +156,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any:
|
||||
return True
|
||||
|
||||
|
||||
_WARNED_KEEPALIVE_IMPORT_SKEW = False
|
||||
|
||||
|
||||
def _openai_http_client_kwargs(
|
||||
base_url: Optional[str],
|
||||
*,
|
||||
async_mode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
|
||||
client = build_keepalive_http_client(
|
||||
str(base_url or ""),
|
||||
async_mode=async_mode,
|
||||
verify=_resolve_aux_verify(base_url),
|
||||
)
|
||||
try:
|
||||
from agent.process_bootstrap import build_keepalive_http_client
|
||||
client = build_keepalive_http_client(
|
||||
str(base_url or ""),
|
||||
async_mode=async_mode,
|
||||
verify=_resolve_aux_verify(base_url),
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
# Version-skewed installs (#64333): a process whose sys.path resolves
|
||||
# an older agent/process_bootstrap.py without this helper — seen when
|
||||
# the Desktop app's bundled runtime lags a git-installed source tree
|
||||
# that newer callers (cron scheduler) were written against. Every cron
|
||||
# job died on this ImportError before any agent logic ran. Degrade
|
||||
# gracefully to the OpenAI SDK's default httpx client (respects macOS
|
||||
# system proxy, no pool-level keepalive expiry) instead of failing the
|
||||
# whole job, and say so once — silent version skew is how this bug
|
||||
# went unnoticed until jobs were already dead on arrival.
|
||||
global _WARNED_KEEPALIVE_IMPORT_SKEW
|
||||
if not _WARNED_KEEPALIVE_IMPORT_SKEW:
|
||||
_WARNED_KEEPALIVE_IMPORT_SKEW = True
|
||||
logger.warning(
|
||||
"agent.process_bootstrap.build_keepalive_http_client is "
|
||||
"unavailable — mixed/stale install detected (#64333). Falling "
|
||||
"back to the SDK default HTTP client. Run `hermes update` (or "
|
||||
"reinstall the Desktop app) to resync the runtime."
|
||||
)
|
||||
client = None
|
||||
|
||||
if client is None:
|
||||
return {}
|
||||
return {"http_client": client}
|
||||
|
||||
|
||||
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
|
||||
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
|
||||
# Hermes owns auxiliary retry + provider/model fallback policy (the
|
||||
@@ -1337,6 +1362,31 @@ class _AnthropicCompletionsAdapter:
|
||||
if not _forbids_sampling_params(model):
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
# Pass through caller-supplied extra_body so providers behind
|
||||
# Anthropic-compatible gateways receive their per-vendor request
|
||||
# fields (thinking control, metadata, portal tags, ...). The dict
|
||||
# form is the documented Anthropic SDK passthrough for non-standard
|
||||
# request body keys; merge on top of whatever build_anthropic_kwargs
|
||||
# already produced (e.g. fast-mode ``speed``) so call-time settings
|
||||
# survive. Two exclusions:
|
||||
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
|
||||
# the native ``thinking`` field above (build_anthropic_kwargs);
|
||||
# forwarding the raw field alongside would double-specify
|
||||
# reasoning and 400 on strict gateways.
|
||||
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
|
||||
# et al.), never wire fields.
|
||||
caller_extra_body = kwargs.get("extra_body")
|
||||
if caller_extra_body and isinstance(caller_extra_body, dict):
|
||||
passthrough = {
|
||||
k: v for k, v in caller_extra_body.items()
|
||||
if k != "reasoning" and not str(k).startswith("_")
|
||||
}
|
||||
if passthrough:
|
||||
existing = anthropic_kwargs.get("extra_body") or {}
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
anthropic_kwargs["extra_body"] = {**existing, **passthrough}
|
||||
|
||||
response = create_anthropic_message(self._client, anthropic_kwargs)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
@@ -3644,6 +3694,40 @@ def _auth_refresh_provider_for_route(
|
||||
return normalized
|
||||
|
||||
|
||||
def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]:
|
||||
"""Resolve a per-entry ``timeout`` for a configured fallback candidate.
|
||||
|
||||
A fallback candidate previously inherited the exact timeout the primary
|
||||
provider was called with. When that deadline was tuned for the primary
|
||||
(or the primary simply consumed its whole budget before failing over),
|
||||
the fallback aborted on the same clock even when independently healthy —
|
||||
a 163k-token compression that needs ~90s on the fallback died at the
|
||||
primary's 30s deadline every turn (#62452).
|
||||
|
||||
Entries in ``auxiliary.<task>.fallback_chain`` may declare their own
|
||||
``timeout`` (seconds). This helper reads it by parsing the entry index
|
||||
out of the label minted by :func:`_try_configured_fallback_chain`
|
||||
(``fallback_chain[<i>](<provider>)`` — our own stable format). Returns
|
||||
``None`` when the label is not a configured-chain candidate, the entry
|
||||
has no ``timeout``, or the value is invalid — callers then keep the
|
||||
task-level timeout, preserving existing behavior.
|
||||
"""
|
||||
if not task or not fb_label:
|
||||
return None
|
||||
m = re.match(r"fallback_chain\[(\d+)\]", fb_label)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
chain = _get_auxiliary_task_config(task).get("fallback_chain")
|
||||
entry = chain[int(m.group(1))] if isinstance(chain, list) else None
|
||||
raw = entry.get("timeout") if isinstance(entry, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0:
|
||||
return float(raw)
|
||||
return None
|
||||
|
||||
|
||||
def _call_fallback_candidate_sync(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
@@ -3672,7 +3756,20 @@ def _call_fallback_candidate_sync(
|
||||
once with a rebuilt client; if the retry also auth-fails (non-refreshable
|
||||
expired token), mark the provider unhealthy and return ``None`` so the
|
||||
caller can continue to the next fallback layer. Non-auth errors raise.
|
||||
|
||||
``effective_timeout`` is the task-level deadline; a configured-chain
|
||||
candidate with its own ``timeout`` entry gets that instead, so a
|
||||
fallback tuned differently from the primary is allowed its own budget
|
||||
(#62452).
|
||||
"""
|
||||
fb_timeout = _fallback_entry_timeout(task, fb_label)
|
||||
if fb_timeout is not None and fb_timeout != effective_timeout:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s using its configured timeout %.0fs "
|
||||
"(task-level was %.0fs)",
|
||||
task or "call", fb_label, fb_timeout, effective_timeout,
|
||||
)
|
||||
effective_timeout = fb_timeout
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
@@ -3731,6 +3828,14 @@ async def _call_fallback_candidate_async(
|
||||
reasoning_config: Optional[dict],
|
||||
) -> Optional[Any]:
|
||||
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
|
||||
fb_timeout = _fallback_entry_timeout(task, fb_label)
|
||||
if fb_timeout is not None and fb_timeout != effective_timeout:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s using its configured timeout %.0fs "
|
||||
"(task-level was %.0fs)",
|
||||
task or "call", fb_label, fb_timeout, effective_timeout,
|
||||
)
|
||||
effective_timeout = fb_timeout
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
|
||||
@@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:
|
||||
mime_part = header[5:].split(";")[0]
|
||||
if mime_part:
|
||||
media_type = mime_part
|
||||
# Decode base64 to raw bytes — boto3 re-encodes at the
|
||||
# wire layer, so passing the base64 string directly
|
||||
# results in double-encoding and Bedrock rejects it with
|
||||
# "Failed to sanitize image". Ref: #33317.
|
||||
import base64
|
||||
try:
|
||||
raw_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
raw_bytes = data.encode("utf-8")
|
||||
blocks.append({
|
||||
"image": {
|
||||
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
|
||||
"source": {"bytes": data},
|
||||
"source": {"bytes": raw_bytes},
|
||||
}
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -504,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
if _codex_floor:
|
||||
_stale_timeout = max(_stale_timeout, _codex_floor)
|
||||
|
||||
# ── Codex absolute hard ceiling (#64507) ──────────────────────────
|
||||
# ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to
|
||||
# 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted.
|
||||
# The scaled no-byte TTFB watchdog catches dead streams that never emit a
|
||||
# first byte, but a request that emits SOME bytes and then wedges (the
|
||||
# issue-64507 symptom: vision-inflated request, worker idle, no ended_at)
|
||||
# is only reclaimed at the (high) stale floor. Add a flat, finite hard
|
||||
# ceiling on total request time that ALWAYS applies to openai-codex
|
||||
# requests regardless of the TTFB/stale interaction, so a stalled request
|
||||
# is recovered (retry loop / visible failure) instead of hanging
|
||||
# indefinitely. The default sits ABOVE the maximum stale floor (1200s) so
|
||||
# it never clamps an intentionally-raised timeout for healthy large
|
||||
# requests — it is a backstop against unbounded growth, not a tighter
|
||||
# limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to
|
||||
# disable the ceiling entirely; that restores the pre-fix behavior).
|
||||
_codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
|
||||
if (
|
||||
_codex_watchdog_enabled
|
||||
and _openai_codex_backend
|
||||
and _codex_hard_timeout > 0
|
||||
):
|
||||
_stale_timeout = min(_stale_timeout, _codex_hard_timeout)
|
||||
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_codex_idle_timeout_default = 180.0
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
|
||||
@@ -1118,6 +1118,22 @@ def _normalize_codex_response(
|
||||
differs from the one that minted the encrypted_content blob and drop
|
||||
the item instead of triggering HTTP 400 invalid_encrypted_content.
|
||||
"""
|
||||
response_status = getattr(response, "status", None)
|
||||
if isinstance(response_status, str):
|
||||
response_status = response_status.strip().lower()
|
||||
else:
|
||||
response_status = None
|
||||
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
incomplete_reason = ""
|
||||
if isinstance(incomplete_details, dict):
|
||||
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
|
||||
elif incomplete_details is not None:
|
||||
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
|
||||
response_incomplete_content_filter = (
|
||||
response_status == "incomplete" and incomplete_reason == "content_filter"
|
||||
)
|
||||
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
# The Codex backend can return empty output when the answer was
|
||||
@@ -1134,15 +1150,18 @@ def _normalize_codex_response(
|
||||
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
|
||||
)]
|
||||
response.output = output
|
||||
elif response_incomplete_content_filter:
|
||||
# This is a deterministic provider safety block, not a partial
|
||||
# answer. Synthesize an empty message so finish_reason below becomes
|
||||
# content_filter and the conversation loop can fallback/surface it
|
||||
# instead of burning three continuation attempts.
|
||||
output = [SimpleNamespace(
|
||||
type="message", role="assistant", status="completed", content=[]
|
||||
)]
|
||||
response.output = output
|
||||
else:
|
||||
raise RuntimeError("Responses API returned no output items")
|
||||
|
||||
response_status = getattr(response, "status", None)
|
||||
if isinstance(response_status, str):
|
||||
response_status = response_status.strip().lower()
|
||||
else:
|
||||
response_status = None
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
error_msg = _format_responses_error(error_obj, response_status)
|
||||
@@ -1411,6 +1430,8 @@ def _normalize_codex_response(
|
||||
|
||||
if tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
elif response_incomplete_content_filter:
|
||||
finish_reason = "content_filter"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
|
||||
@@ -586,6 +586,27 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
|
||||
return result if changed else messages
|
||||
|
||||
|
||||
def _image_part_label(part: Dict[str, Any]) -> str:
|
||||
"""Render a multimodal image part as a short text label for the summarizer.
|
||||
|
||||
Keeps a real, referenceable URL when the image lives at an http(s)
|
||||
address — the summary can then preserve the handle so the agent (or a
|
||||
later vision_analyze call) can still reach the image after compaction.
|
||||
Base64 ``data:`` URLs carry no reusable reference and would flood the
|
||||
summarizer input, so they collapse to ``[image]``.
|
||||
"""
|
||||
url = ""
|
||||
if isinstance(part.get("image_url"), dict):
|
||||
url = str(part["image_url"].get("url") or "")
|
||||
elif isinstance(part.get("image_url"), str):
|
||||
url = part["image_url"]
|
||||
elif isinstance(part.get("url"), str):
|
||||
url = part["url"]
|
||||
if url.startswith(("http://", "https://")):
|
||||
return f"[image: {url}]"
|
||||
return "[image]"
|
||||
|
||||
|
||||
def _str_arg(args: dict, key: str, default: str = "") -> str:
|
||||
"""Safely get a string argument from parsed tool args.
|
||||
|
||||
@@ -686,7 +707,16 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
|
||||
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
first = urls[0] if isinstance(urls, list) and urls else "?"
|
||||
# web_search results are dicts ({"url"/"href": ...}) and models often
|
||||
# forward them straight into web_extract. Unwrap to the URL string so
|
||||
# the summary stays readable and the ``+=`` below never hits the
|
||||
# ``dict + str`` TypeError that would abort pre-compression pruning.
|
||||
if isinstance(first, dict):
|
||||
first = first.get("url") or first.get("href") or "?"
|
||||
elif not isinstance(first, str):
|
||||
first = "?"
|
||||
url_desc = first
|
||||
if isinstance(urls, list) and len(urls) > 1:
|
||||
url_desc += f" (+{len(urls) - 1} more)"
|
||||
return f"[web_extract] {url_desc} ({content_len:,} chars)"
|
||||
@@ -765,6 +795,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._context_probe_persistable = False
|
||||
self._previous_summary = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
@@ -803,6 +834,7 @@ class ContextCompressor(ContextEngine):
|
||||
"""
|
||||
self._previous_summary = None
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
@@ -827,6 +859,7 @@ class ContextCompressor(ContextEngine):
|
||||
self._session_id = session_id or ""
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
self._fallback_compression_streak = 0
|
||||
self.get_active_compression_failure_cooldown()
|
||||
self._load_fallback_compression_streak()
|
||||
@@ -975,6 +1008,7 @@ class ContextCompressor(ContextEngine):
|
||||
def _clear_compression_failure_cooldown(self) -> None:
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_summary_error = None
|
||||
self._consecutive_timeout_failures = 0
|
||||
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
session_id = getattr(self, "_session_id", "")
|
||||
@@ -1655,7 +1689,24 @@ class ContextCompressor(ContextEngine):
|
||||
parts = []
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
text_parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
elif ptype in {"image", "image_url", "input_image"}:
|
||||
text_parts.append(_image_part_label(part))
|
||||
else:
|
||||
# Unknown part type — keep a marker so the
|
||||
# summarizer knows content existed here.
|
||||
text_parts.append(f"[{ptype or 'attachment'}]")
|
||||
elif isinstance(part, str):
|
||||
text_parts.append(part)
|
||||
content = "\n".join(text_parts)
|
||||
content = redact_sensitive_text(content or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
|
||||
# assistant content before it reaches the summarizer. Reasoning
|
||||
@@ -2241,6 +2292,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
_is_timeout = (
|
||||
_status in {408, 429, 502, 504}
|
||||
or "timeout" in _err_str
|
||||
or "timed out" in _err_str
|
||||
)
|
||||
# Non-JSON / malformed-body responses from misconfigured providers
|
||||
# or proxies (e.g. an HTML 502 page returned with
|
||||
@@ -2330,7 +2382,30 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# Transient errors (timeout, rate limit, network, JSON decode,
|
||||
# streaming premature-close) — shorter cooldown for JSON decode and
|
||||
# streaming-closed since those conditions can self-resolve quickly.
|
||||
_transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60
|
||||
# Timeout-class failures escalate with consecutive occurrences:
|
||||
# a session whose transcript structurally exceeds what the
|
||||
# summary route can produce within its deadline will fail the
|
||||
# same way every time, and re-burning the full timeout every
|
||||
# 60s turns each subsequent turn into a multi-minute stall
|
||||
# (#62452). 60s → 300s → 900s (capped); any successful summary
|
||||
# resets the streak via _clear_compression_failure_cooldown().
|
||||
# Timeout takes precedence over the streaming-closed short rung:
|
||||
# a "timed out" error also matches _is_connection_error, but a
|
||||
# deadline exhaustion is the structural repeat-offender class,
|
||||
# not a transient mid-stream drop.
|
||||
if _is_timeout:
|
||||
self._consecutive_timeout_failures = (
|
||||
getattr(self, "_consecutive_timeout_failures", 0) + 1
|
||||
)
|
||||
_TIMEOUT_COOLDOWN_LADDER = (60, 300, 900)
|
||||
_transient_cooldown = _TIMEOUT_COOLDOWN_LADDER[
|
||||
min(self._consecutive_timeout_failures,
|
||||
len(_TIMEOUT_COOLDOWN_LADDER)) - 1
|
||||
]
|
||||
elif _is_json_decode or _is_streaming_closed:
|
||||
_transient_cooldown = 30
|
||||
else:
|
||||
_transient_cooldown = 60
|
||||
err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(err_text) > 220:
|
||||
err_text = err_text[:217].rstrip() + "..."
|
||||
|
||||
+47
-15
@@ -1642,12 +1642,16 @@ def run_conversation(
|
||||
# Check finish_reason before proceeding
|
||||
if agent.api_mode == "codex_responses":
|
||||
status = getattr(response, "status", None)
|
||||
if isinstance(status, str):
|
||||
status = status.strip().lower()
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
incomplete_reason = None
|
||||
if isinstance(incomplete_details, dict):
|
||||
incomplete_reason = incomplete_details.get("reason")
|
||||
else:
|
||||
incomplete_reason = getattr(incomplete_details, "reason", None)
|
||||
if incomplete_reason is not None:
|
||||
incomplete_reason = str(incomplete_reason).strip().lower()
|
||||
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
|
||||
# Responses API max-output exhaustion is a normal
|
||||
# Codex incomplete turn. Let the Codex-specific
|
||||
@@ -1657,6 +1661,8 @@ def run_conversation(
|
||||
# emits "Response truncated due to output length
|
||||
# limit" and stops gateway turns.
|
||||
finish_reason = "incomplete"
|
||||
elif status == "incomplete" and incomplete_reason == "content_filter":
|
||||
finish_reason = "content_filter"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
elif agent.api_mode == "anthropic_messages":
|
||||
@@ -2619,6 +2625,31 @@ def run_conversation(
|
||||
)
|
||||
continue
|
||||
|
||||
# ── Bedrock AnthropicBedrock SDK streaming failure ──
|
||||
# The Anthropic SDK's stream accumulator raises RuntimeError
|
||||
# "Unexpected event order" when Bedrock returns an error event
|
||||
# before message_start (throttling, overload, validation).
|
||||
# Fall back to the native Converse API path for the rest of
|
||||
# this session — it handles these errors gracefully. Ref: #28156.
|
||||
if (
|
||||
isinstance(api_error, RuntimeError)
|
||||
and "unexpected event order" in str(api_error).lower()
|
||||
and getattr(agent, "provider", "") == "bedrock"
|
||||
and agent.api_mode == "anthropic_messages"
|
||||
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
|
||||
):
|
||||
agent._bedrock_converse_fallback_attempted = True
|
||||
agent.api_mode = "bedrock_converse"
|
||||
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
|
||||
agent.client = None # Drop the AnthropicBedrock client
|
||||
agent._client_kwargs = {}
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
|
||||
f"falling back to native Converse API for this session.",
|
||||
force=True,
|
||||
)
|
||||
continue
|
||||
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
error_context = agent._extract_api_error_context(api_error)
|
||||
|
||||
@@ -4459,27 +4490,28 @@ def run_conversation(
|
||||
or interim_has_codex_message_items
|
||||
):
|
||||
last_msg = messages[-1] if messages else None
|
||||
# Duplicate detection: two consecutive incomplete assistant
|
||||
# messages with identical content AND reasoning are collapsed.
|
||||
# For provider-state-only changes (encrypted reasoning
|
||||
# items or replayable message ids/phases/statuses differ
|
||||
# while visible content/reasoning are unchanged), compare
|
||||
# those opaque payloads too so we don't silently drop the
|
||||
# newer continuation state.
|
||||
last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None
|
||||
interim_codex_items = interim_msg.get("codex_reasoning_items")
|
||||
last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None
|
||||
interim_codex_message_items = interim_msg.get("codex_message_items")
|
||||
duplicate_interim = (
|
||||
# Duplicate detection: compare only visible content
|
||||
# (content + reasoning). Opaque provider state
|
||||
# (encrypted reasoning items, message item ids/phases)
|
||||
# drifts per continuation even when the visible output
|
||||
# is identical, so including it in the comparison defeats
|
||||
# dedup and causes message storms (#52711).
|
||||
visible_duplicate = (
|
||||
isinstance(last_msg, dict)
|
||||
and last_msg.get("role") == "assistant"
|
||||
and last_msg.get("finish_reason") == "incomplete"
|
||||
and (last_msg.get("content") or "") == (interim_msg.get("content") or "")
|
||||
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
|
||||
and last_codex_items == interim_codex_items
|
||||
and last_codex_message_items == interim_codex_message_items
|
||||
)
|
||||
if not duplicate_interim:
|
||||
if visible_duplicate:
|
||||
# Update opaque state in-place so the latest
|
||||
# provider payload is preserved without emitting
|
||||
# a duplicate visible message.
|
||||
for _key in ("codex_reasoning_items", "codex_message_items"):
|
||||
_new_val = interim_msg.get(_key)
|
||||
if _new_val is not None:
|
||||
last_msg[_key] = _new_val
|
||||
else:
|
||||
messages.append(interim_msg)
|
||||
agent._emit_interim_assistant_message(interim_msg)
|
||||
|
||||
|
||||
@@ -840,12 +840,34 @@ def classify_api_error(
|
||||
)
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 7. Transport / timeout heuristics ───────────────────────────
|
||||
# ── 7b. Stale-call circuit breaker → failover immediately ──────
|
||||
# _check_stale_giveup() in agent/chat_completion_helpers.py raises a
|
||||
# RuntimeError when the provider has been unresponsive for N
|
||||
# consecutive stale attempts (default 5). The error is NOT a transport
|
||||
# timeout — the circuit breaker fires *before* any network call to avoid
|
||||
# an indefinite stall. Without this classification the RuntimeError
|
||||
# falls through to FailoverReason.unknown (retryable=True), which burns
|
||||
# all max_retries against the same dead provider (each retry hitting the
|
||||
# circuit breaker instantly with zero network overhead) before fallback
|
||||
# is attempted. Classify as non-retryable + should_fallback so the
|
||||
# retry loop activates the next fallback provider on the first hit.
|
||||
if (
|
||||
error_type == "RuntimeError"
|
||||
and "consecutive stale attempts" in error_msg
|
||||
and "aborting this call" in error_msg
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.timeout,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# ── 8. Transport / timeout heuristics ───────────────────────────
|
||||
|
||||
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 8. Fallback: unknown ────────────────────────────────────────
|
||||
# ── 9. Fallback: unknown ────────────────────────────────────────
|
||||
|
||||
return _result(FailoverReason.unknown, retryable=True)
|
||||
|
||||
|
||||
+24
-2
@@ -55,10 +55,32 @@ def hermes_client_tag() -> str:
|
||||
return f"client=hermes-client-v{_hermes_version()}"
|
||||
|
||||
|
||||
def nous_portal_tags() -> List[str]:
|
||||
def conversation_tag(session_id: str) -> str:
|
||||
"""Return the ``conversation=...`` tag for a Hermes session/conversation.
|
||||
|
||||
Format: ``conversation=<session_id>``. ``session_id`` is the canonical
|
||||
Hermes conversation identifier (``AIAgent.session_id``) — the same value
|
||||
used for ``~/.hermes/sessions/`` storage, session logs, and lineage.
|
||||
|
||||
Unlike the product/client tags this is high-cardinality (one value per
|
||||
conversation), so it is only appended when a session id is actually
|
||||
available — never as part of the always-on base tag set.
|
||||
"""
|
||||
return f"conversation={session_id}"
|
||||
|
||||
|
||||
def nous_portal_tags(session_id: str | None = None) -> List[str]:
|
||||
"""Return the canonical list of Nous Portal product tags.
|
||||
|
||||
Always returns a fresh list so callers can mutate it freely
|
||||
(e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``).
|
||||
|
||||
When ``session_id`` is provided, a ``conversation=<session_id>`` tag is
|
||||
appended so Portal usage can be attributed to a specific Hermes
|
||||
conversation. Callers without a session id (e.g. the auxiliary client's
|
||||
always-on base tags) omit it and get the canonical two-tag set.
|
||||
"""
|
||||
return ["product=hermes-agent", hermes_client_tag()]
|
||||
tags = ["product=hermes-agent", hermes_client_tag()]
|
||||
if session_id:
|
||||
tags.append(conversation_tag(session_id))
|
||||
return tags
|
||||
|
||||
+14
-4
@@ -208,19 +208,29 @@ class StreamingThinkScrubber:
|
||||
discarded — leaking partial reasoning is worse than a
|
||||
truncated answer. Otherwise the held-back partial-tag tail is
|
||||
emitted verbatim (it turned out not to be a real tag prefix).
|
||||
|
||||
Always treats the next ``feed()`` as a fresh stream boundary.
|
||||
Intra-turn retries (thinking-only prefill, empty-response
|
||||
retry) flush then stream again without calling ``reset()``;
|
||||
leaving ``_last_emitted_ended_newline`` False made a new
|
||||
stream's opening ``<think>`` look mid-line and leak into the
|
||||
visible reply.
|
||||
"""
|
||||
if self._in_block:
|
||||
self._buf = ""
|
||||
self._in_block = False
|
||||
# Next feed() is a new stream — start-of-stream is a boundary.
|
||||
self._last_emitted_ended_newline = True
|
||||
return ""
|
||||
tail = self._buf
|
||||
self._buf = ""
|
||||
# Same for the non-block path: do NOT derive the boundary flag
|
||||
# from the flushed tail (e.g. a held-back '<'). End-of-stream
|
||||
# means the next feed() starts a new model response.
|
||||
self._last_emitted_ended_newline = True
|
||||
if not tail:
|
||||
return ""
|
||||
tail = self._strip_orphan_close_tags(tail)
|
||||
if tail:
|
||||
self._last_emitted_ended_newline = tail.endswith("\n")
|
||||
return tail
|
||||
return self._strip_orphan_close_tags(tail)
|
||||
|
||||
# ── internal helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -435,15 +435,27 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Codex Responses API response has valid output structure.
|
||||
|
||||
Returns True only if response.output is a non-empty list.
|
||||
Does NOT check output_text fallback — the caller handles that
|
||||
with diagnostic logging for stream backfill recovery.
|
||||
Returns True only if response.output is a non-empty list. Also treats
|
||||
terminal content-filter incomplete responses as valid: the Responses API
|
||||
may return status=incomplete with incomplete_details.reason='content_filter'
|
||||
and no output items. That is a provider refusal signal, not a malformed
|
||||
response, and must reach normalization so the agent loop can use the
|
||||
content-policy / fallback path instead of invalid-response retries.
|
||||
|
||||
Does NOT check output_text fallback — the caller handles that with
|
||||
diagnostic logging for stream backfill recovery.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
return False
|
||||
status = str(getattr(response, "status", "") or "").strip().lower()
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
if isinstance(incomplete_details, dict):
|
||||
reason = str(incomplete_details.get("reason") or "").strip().lower()
|
||||
else:
|
||||
reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
|
||||
return status == "incomplete" and reason == "content_filter"
|
||||
return True
|
||||
|
||||
def preflight_kwargs(
|
||||
|
||||
@@ -262,3 +262,40 @@ test('addWorktree: base param branches off a specified local branch', async () =
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base origin/main does not set up upstream tracking', async () => {
|
||||
// Two repos: a bare "remote" and a clone, so origin/main resolves as a
|
||||
// remote-tracking ref — the condition that triggers auto-tracking.
|
||||
const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-'))
|
||||
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-clone-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: cloneDir }).toString().trim()
|
||||
|
||||
try {
|
||||
// Seed the remote with a commit on main. Inline identity so it works
|
||||
// on CI runners with no global git config.
|
||||
execFileSync('git', ['init', '-b', 'main', remoteDir])
|
||||
execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root'])
|
||||
|
||||
// Clone so origin/main exists as a remote-tracking ref.
|
||||
execFileSync('git', ['clone', remoteDir, cloneDir])
|
||||
|
||||
const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'feature-branch')
|
||||
|
||||
// The new branch must NOT have an upstream — like `git checkout origin/main
|
||||
// && git checkout -b feature-branch`, not `git worktree add -b … origin/main`.
|
||||
let hasUpstream = true
|
||||
|
||||
try {
|
||||
execFileSync('git', ['-C', result.path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
|
||||
} catch {
|
||||
hasUpstream = false
|
||||
}
|
||||
|
||||
assert.equal(hasUpstream, false)
|
||||
} finally {
|
||||
fs.rmSync(remoteDir, { recursive: true, force: true })
|
||||
fs.rmSync(cloneDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -265,8 +265,15 @@ async function addWorktree(repoPath, options, gitBin) {
|
||||
} catch {
|
||||
// The fetch isn't mandatory, but it would be nice to do if possible.
|
||||
// If it's not possible, just use the local ref of the remote branch.
|
||||
// If it doesn't exist locally, we'll get an error anyways
|
||||
// If it doesn't exist locally, we'll get an error
|
||||
}
|
||||
|
||||
// When branching off a remote-tracking ref, git auto-sets up tracking
|
||||
// (e.g. `new-branch` → tracks `origin/main`). The user almost certainly
|
||||
// wants a standalone local branch — like `git checkout origin/main &&
|
||||
// git checkout -b new-branch` — not a branch silently wired to the
|
||||
// remote's upstream. `--no-track` prevents that.
|
||||
args.push('--no-track')
|
||||
}
|
||||
|
||||
args.push(base)
|
||||
|
||||
@@ -3597,9 +3597,35 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
|
||||
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
|
||||
// FormData and the payload is one file — a dependency would be overkill.
|
||||
function multipartBody(upload) {
|
||||
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
|
||||
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
|
||||
),
|
||||
Buffer.from(upload.bytes),
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
])
|
||||
|
||||
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const { body, contentType } = options.upload
|
||||
? multipartBody(options.upload)
|
||||
: {
|
||||
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
@@ -3615,7 +3641,7 @@ function fetchJson(url, token, options: any = {}) {
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': contentType,
|
||||
'X-Hermes-Session-Token': token,
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
@@ -4576,14 +4602,13 @@ function buildApplicationMenu() {
|
||||
submenu: [
|
||||
IS_MAC
|
||||
? {
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click: () => {
|
||||
if (previewShortcutActive) {
|
||||
sendClosePreviewRequested()
|
||||
} else {
|
||||
mainWindow?.close()
|
||||
}
|
||||
},
|
||||
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
|
||||
// menu before the web contents ever sees it (and registerAccelerator
|
||||
// false is a no-op on mac — electron#18295). Leaving it off lets the
|
||||
// `before-input-event` handler below intercept ⌘W and route it to the
|
||||
// renderer's close-active-tab. Clicking the item still closes the tab
|
||||
// (or window) via the same request.
|
||||
click: () => sendClosePreviewRequested(),
|
||||
label: 'Close'
|
||||
}
|
||||
: { role: 'quit' }
|
||||
@@ -4689,9 +4714,12 @@ function installDevToolsShortcut(window) {
|
||||
function installPreviewShortcut(window) {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
|
||||
if (!isPreviewCloseShortcut || !previewShortcutActive) {
|
||||
// Always claim ⌘W here (the File>Close item deliberately has no
|
||||
// accelerator, so nothing else does). The renderer decides tab-vs-window
|
||||
// — no `previewShortcutActive` gate, so it works for every closeable tab.
|
||||
if (!isCloseTabShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7870,6 +7898,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
// the static session-token header.
|
||||
if (connection.authMode === 'oauth') {
|
||||
// The OAuth path rides electron.net with JSON headers; multipart isn't
|
||||
// wired there. Fail loudly rather than corrupting the upload.
|
||||
if (request?.upload) {
|
||||
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
|
||||
}
|
||||
|
||||
return fetchJsonViaOauthSession(url, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
@@ -7880,6 +7914,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
return fetchJson(url, connection.token, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
upload: request?.upload,
|
||||
timeoutMs
|
||||
})
|
||||
})
|
||||
@@ -8380,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
|
||||
@@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
|
||||
@@ -104,6 +104,25 @@ export default [
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
// THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…`
|
||||
// internals — the same isolation a runtime-fetched published plugin gets,
|
||||
// enforced on bundled ones so the SDK surface stays honest and sufficient.
|
||||
files: ['src/plugins/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/*', '../*', '@hermes/shared'],
|
||||
message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ICONS: Record<'files' | 'session', string> = {
|
||||
files: 'cloud-upload',
|
||||
session: 'comment-discussion'
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||
* Copy adapts to whatever is being dragged; the last kind is held through the
|
||||
* fade-out so the label doesn't blank.
|
||||
* The label names the outcome (attach files / link this chat); the last kind is
|
||||
* held through the fade-out so it doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const { t } = useI18n()
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
const lastKind = useRef<DragKind>(kind)
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const resolvedKind = kind ?? lastKind.current
|
||||
const icon = ICONS[resolvedKind]
|
||||
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
|
||||
const shown = kind ?? lastKind.current
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
DROP_SHEET_CLASS,
|
||||
DROP_SHEET_BLUR_CLASS,
|
||||
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
{shown && (
|
||||
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
|
||||
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
*/
|
||||
export function closeActiveTab(): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
closeActiveTerminal()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
closeActiveRightRailTab()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
||||
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
|
||||
// before the whole row has to stack. Progressive collapse: full pill → icon
|
||||
// pill → stacked.
|
||||
export const COMPOSER_COMPACT_PILL_PX = 440
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
// which is when the composer should expand to the stacked layout.
|
||||
@@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent(
|
||||
pending: PendingDraftPersist | null,
|
||||
expected: PendingDraftPersist | null
|
||||
): boolean {
|
||||
return (
|
||||
pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
)
|
||||
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useComposerAttachmentProviders } from './contrib'
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
@@ -39,6 +40,9 @@ export function ContextMenu({
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
// `composer.attachments` contributions — plugin/core-registered rows that
|
||||
// extend this menu through the same registry as every other surface.
|
||||
const attachmentProviders = useComposerAttachmentProviders()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,6 +94,18 @@ export function ContextMenu({
|
||||
{c.promptSnippets}
|
||||
</ContextMenuItem>
|
||||
|
||||
{attachmentProviders.length > 0 && <DropdownMenuSeparator />}
|
||||
{attachmentProviders.map(provider => (
|
||||
<DropdownMenuItem
|
||||
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
|
||||
key={provider.key}
|
||||
onSelect={() => void provider.run({ insertText: onInsertText })}
|
||||
>
|
||||
<Codicon name={provider.icon ?? 'plug'} size="0.875rem" />
|
||||
<span>{provider.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
|
||||
disposers.push(
|
||||
registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
disposers.splice(0).forEach(d => d())
|
||||
})
|
||||
|
||||
describe('runComposerMiddleware', () => {
|
||||
it('passes the draft through untouched when nothing is registered', async () => {
|
||||
const draft = { text: 'hello' }
|
||||
|
||||
expect(await runComposerMiddleware(draft)).toBe(draft)
|
||||
})
|
||||
|
||||
it('chains rewrites in registry order', async () => {
|
||||
addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
|
||||
addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
|
||||
})
|
||||
|
||||
it('cancels the send when a handler returns null', async () => {
|
||||
addMiddleware('gate', () => null)
|
||||
addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a throwing handler as pass-through', async () => {
|
||||
addMiddleware('boom', () => {
|
||||
throw new Error('broken plugin')
|
||||
})
|
||||
addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
|
||||
})
|
||||
|
||||
it('supports async handlers', async () => {
|
||||
addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
|
||||
|
||||
expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composer contribution surface — every seam of the composer is hook-into-able
|
||||
* through the SAME registry schema as every other surface (statusbar, titlebar,
|
||||
* panes, layouts):
|
||||
*
|
||||
* render areas (`render`): composer.top — banner strip above the input
|
||||
* composer.bottom — row below the input grid
|
||||
* composer.leading — inline after the "+" menu
|
||||
* composer.actions — inline before the model pill
|
||||
*
|
||||
* data kinds (`data`): composer.middleware (ComposerMiddleware)
|
||||
* composer.attachments (ComposerAttachmentProvider)
|
||||
*
|
||||
* Core keeps ownership of the transcript, input, and submit engine — these
|
||||
* seams AUGMENT the composer, they never replace it. Middleware runs as an
|
||||
* ordered async chain around the app's onSubmit: each handler may rewrite the
|
||||
* draft, pass it through, or cancel the send by returning null.
|
||||
*/
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
export const COMPOSER_AREAS = {
|
||||
top: 'composer.top',
|
||||
bottom: 'composer.bottom',
|
||||
leading: 'composer.leading',
|
||||
actions: 'composer.actions',
|
||||
middleware: 'composer.middleware',
|
||||
attachments: 'composer.attachments'
|
||||
} as const
|
||||
|
||||
export interface ComposerDraft {
|
||||
text: string
|
||||
attachments?: ComposerAttachment[]
|
||||
}
|
||||
|
||||
/** Payload of a `composer.middleware` data contribution. */
|
||||
export interface ComposerMiddleware {
|
||||
/** Rewrite (return a draft), pass through (same draft), or cancel (null). */
|
||||
handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null>
|
||||
}
|
||||
|
||||
export interface ComposerAttachmentContext {
|
||||
insertText: (text: string) => void
|
||||
}
|
||||
|
||||
/** Payload of a `composer.attachments` data contribution — an entry in the
|
||||
* composer's "+" attach menu. */
|
||||
export interface ComposerAttachmentProvider {
|
||||
label: string
|
||||
/** Codicon name for the menu row. Defaults to `plug`. */
|
||||
icon?: string
|
||||
run: (ctx: ComposerAttachmentContext) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ordered middleware chain over a draft. Contributions execute in
|
||||
* registry order (`order`, then registration order); the first `null` wins
|
||||
* and cancels the send. A throwing handler is treated as pass-through so a
|
||||
* broken plugin can't eat messages.
|
||||
*/
|
||||
export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> {
|
||||
let current = draft
|
||||
|
||||
for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
|
||||
const middleware = contribution.data as ComposerMiddleware | undefined
|
||||
|
||||
if (!middleware?.handler) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await middleware.handler(current)
|
||||
|
||||
if (next === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
current = next
|
||||
} catch {
|
||||
// Pass-through: a faulty middleware must never swallow the message.
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/** Attach-menu entries contributed by plugins/core, with stable render keys. */
|
||||
export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> {
|
||||
return useContributions(COMPOSER_AREAS.attachments)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
|
||||
.filter(p => Boolean(p.label && p.run))
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
/** Composer routing key. The main chat is `'main'`, the edit composer
|
||||
* `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */
|
||||
export type ComposerTarget = 'edit' | 'main' | (string & {})
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
interface FocusDetail {
|
||||
@@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
|
||||
activeTarget = target
|
||||
}
|
||||
|
||||
/** The composer that last held focus — the target `'active'` resolves to.
|
||||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
|
||||
export const getActiveComposer = (): ComposerTarget => activeTarget
|
||||
|
||||
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
|
||||
|
||||
@@ -129,12 +135,14 @@ export const requestComposerSubmit = (
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
|
||||
|
||||
/** Toggle the active composer's voice conversation — the `composer.voice`
|
||||
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
|
||||
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
|
||||
/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey
|
||||
* (Ctrl+B) reaches the composer that owns voice. Defaults to the active
|
||||
* composer so N tiles don't all flip together. */
|
||||
export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
|
||||
|
||||
export const onComposerVoiceToggleRequest = (handler: () => void) =>
|
||||
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
|
||||
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { clearComposerAttachments } from '@/store/composer'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
interface UseComposerBranchOptions {
|
||||
clearDraft: () => void
|
||||
cwd: null | string | undefined
|
||||
@@ -17,6 +18,8 @@ interface UseComposerBranchOptions {
|
||||
* projects store) is the only dependency; nothing about ChatBar's render.
|
||||
*/
|
||||
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Hand a worktree off to the controller: open a fresh session anchored there,
|
||||
// carrying the composer draft as its first turn. Clearing here means the draft
|
||||
// travels to the new session instead of getting stashed under this one.
|
||||
@@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
|
||||
(path: string) => {
|
||||
const text = draftRef.current
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
requestStartWorkSession(path, text)
|
||||
},
|
||||
[clearDraft, draftRef]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { isBrowsingHistory } from '@/store/composer-input-history'
|
||||
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../focus'
|
||||
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
|
||||
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerDraftArgs {
|
||||
@@ -50,6 +51,8 @@ export function useComposerDraft({
|
||||
}: UseComposerDraftArgs) {
|
||||
const aui = useAui()
|
||||
const composerRuntime = useComposerRuntime()
|
||||
// Which composer this is on the focus bus + which attachment set it owns.
|
||||
const { attachments: attachmentScope, target } = useComposerScope()
|
||||
|
||||
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
|
||||
// steerable-vs-slash), so typing within a line costs no render.
|
||||
@@ -98,8 +101,8 @@ export function useComposerDraft({
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
focusComposerInput(editorRef.current)
|
||||
markActiveComposer('main')
|
||||
}, [])
|
||||
markActiveComposer(target)
|
||||
}, [target])
|
||||
|
||||
const requestMainFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
@@ -155,14 +158,14 @@ export function useComposerDraft({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const offFocus = onComposerFocusRequest(target => {
|
||||
if (target === 'main') {
|
||||
const offFocus = onComposerFocusRequest(requested => {
|
||||
if (requested === target) {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'main') {
|
||||
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
|
||||
if (requested === target) {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
@@ -171,13 +174,13 @@ export function useComposerDraft({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, inputDisabled])
|
||||
}, [appendExternalText, inputDisabled, target])
|
||||
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
$composerAttachments.set(cloneAttachments(attachments))
|
||||
attachmentScope.$attachments.set(cloneAttachments(attachments))
|
||||
paintDraft(text, false)
|
||||
}
|
||||
|
||||
@@ -296,12 +299,12 @@ export function useComposerDraft({
|
||||
insertInlineRefsRef.current = insertInlineRefs
|
||||
|
||||
useEffect(() => {
|
||||
return onComposerInsertRefsRequest(({ refs, target }) => {
|
||||
if (target === 'main') {
|
||||
return onComposerInsertRefsRequest(({ refs, target: requested }) => {
|
||||
if (requested === target) {
|
||||
insertInlineRefsRef.current(refs)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [target])
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
import { type ComposerTarget, getActiveComposer } from '../focus'
|
||||
|
||||
interface UseComposerEscCancelOptions {
|
||||
awaitingInput: boolean
|
||||
busy: boolean
|
||||
onCancel: () => unknown
|
||||
/** This composer's focus-bus key. With N composers mounted (main + tiles),
|
||||
* only the active one's Esc cancels — otherwise every busy tile stops. */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions {
|
||||
* the window listener registered exactly once while still reading fresh
|
||||
* busy/awaitingInput/onCancel each press.
|
||||
*/
|
||||
export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) {
|
||||
export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
|
||||
// Intentional only: we bail if (a) the composer/another field already handled
|
||||
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
|
||||
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
|
||||
@@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo
|
||||
return
|
||||
}
|
||||
|
||||
// Only the focused composer cancels — otherwise every mounted busy tile
|
||||
// stops at once (and the winner would be mount-order arbitrary).
|
||||
if (getActiveComposer() !== target) {
|
||||
return
|
||||
}
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { $composerPoppedOut } from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
|
||||
|
||||
interface UseComposerMetricsArgs {
|
||||
composerRef: RefObject<HTMLFormElement | null>
|
||||
@@ -24,10 +24,13 @@ interface UseComposerMetricsArgs {
|
||||
* Returns `stacked` (the only value the render needs).
|
||||
*/
|
||||
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
|
||||
compactPill: boolean
|
||||
stacked: boolean
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [tight, setTight] = useState(false)
|
||||
// Wider than `tight`: the pill goes icon-only before the row has to stack.
|
||||
const [compactPill, setCompactPill] = useState(false)
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
// Edge signals, not the live text: these only re-render when emptiness / the
|
||||
@@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
const lastBucketedHeightRef = useRef(0)
|
||||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
const lastCompactPillRef = useRef<boolean | null>(null)
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
@@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
lastTightRef.current = nextTight
|
||||
setTight(nextTight)
|
||||
}
|
||||
|
||||
const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX
|
||||
|
||||
if (nextCompactPill !== lastCompactPillRef.current) {
|
||||
lastCompactPillRef.current = nextCompactPill
|
||||
setCompactPill(nextCompactPill)
|
||||
}
|
||||
}
|
||||
|
||||
// Expand once the input has actually wrapped past a single line. The
|
||||
@@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { stacked: expanded || narrow || tight }
|
||||
// Pill compacts on real width (tile/pane), OR when stacked for any reason
|
||||
// (viewport-narrow / wrapped) so the controls row never over-runs.
|
||||
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/store/composer-popout'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
import { useComposerPopoutGestures } from './use-popout-drag'
|
||||
|
||||
interface UseComposerPopoutOptions {
|
||||
@@ -25,7 +27,10 @@ interface UseComposerPopoutOptions {
|
||||
* window's composer out via the shared atom.
|
||||
*/
|
||||
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
|
||||
const popoutAllowed = !isSecondaryWindow()
|
||||
// The floating composer is a window-level singleton: only the main scope
|
||||
// (not tiles) in a primary window may pop out.
|
||||
const scope = useComposerScope()
|
||||
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
|
||||
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
|
||||
const popoutPosition = useStore($composerPopoutPosition)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerQueueArgs {
|
||||
@@ -60,6 +61,7 @@ export function useComposerQueue({
|
||||
sessionId
|
||||
}: UseComposerQueueArgs) {
|
||||
const { t } = useI18n()
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Per-session slice (edge): re-renders only when THIS session's queue changes,
|
||||
// not on cross-session queue churn (the plain atom's map ref changes on every
|
||||
@@ -173,11 +175,11 @@ export function useComposerQueue({
|
||||
}
|
||||
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
triggerHaptic('selection')
|
||||
|
||||
return true
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draftRef])
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
|
||||
|
||||
// All queue drain paths share one lock + send-then-remove sequence.
|
||||
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
|
||||
|
||||
@@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { onComposerSubmitRequest } from '../focus'
|
||||
import { composerPlainText } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
interface UseComposerSubmitArgs {
|
||||
@@ -71,6 +72,8 @@ export function useComposerSubmit({
|
||||
setComposerText,
|
||||
stashAt
|
||||
}: UseComposerSubmitArgs) {
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
|
||||
// === false) or throws, re-load + re-stash the draft so the words survive.
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
@@ -163,7 +166,7 @@ export function useComposerSubmit({
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
scope.attachments.clear()
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
import type { ComposerTarget } from '../focus'
|
||||
import { onComposerVoiceToggleRequest } from '../focus'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
@@ -25,6 +26,9 @@ interface UseComposerVoiceArgs {
|
||||
onSubmit: ChatBarProps['onSubmit']
|
||||
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
|
||||
sessionId: string | null | undefined
|
||||
/** This composer's focus-bus key — voice toggles targeting another
|
||||
* composer (or the active one, when not us) are ignored. */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +46,8 @@ export function useComposerVoice({
|
||||
maxRecordingSeconds,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
sessionId
|
||||
sessionId,
|
||||
target
|
||||
}: UseComposerVoiceArgs) {
|
||||
const { t } = useI18n()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
@@ -122,7 +127,10 @@ export function useComposerVoice({
|
||||
}
|
||||
}, [conversation, disabled, voiceConversationActive])
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
useEffect(
|
||||
() => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
|
||||
[target, toggleVoiceConversation]
|
||||
)
|
||||
|
||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||
// the gated toggle above).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slot as ContribSlot } from '@/contrib/react/slot'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments } from '@/store/composer'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { removeQueuedPrompt } from '@/store/composer-queue'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -23,6 +23,7 @@ import { useTheme } from '@/themes'
|
||||
import { AttachmentList } from './attachments'
|
||||
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
|
||||
import { ComposerControls } from './controls'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
import { markActiveComposer } from './focus'
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
normalizeComposerEditorDom,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { useComposerScope } from './scope'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
@@ -79,17 +81,36 @@ export function ChatBar({
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onSubmit: onSubmitProp,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const attachments = useStore($composerAttachments)
|
||||
// Every send (typed, queued, voice) passes through the contributed
|
||||
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
|
||||
// exact pass-through, so surfaces without contributions are byte-identical.
|
||||
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
|
||||
async (value, options) => {
|
||||
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
|
||||
|
||||
if (!draft) {
|
||||
return false
|
||||
}
|
||||
|
||||
return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
|
||||
},
|
||||
[onSubmitProp]
|
||||
)
|
||||
|
||||
// Which live composer this instance IS (main | tile) — its attachment set,
|
||||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
const awaitingInput = useStore(scope.$awaitingInput)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
@@ -188,7 +209,7 @@ export function ChatBar({
|
||||
|
||||
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
|
||||
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
@@ -197,8 +218,6 @@ export function ChatBar({
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
const showHelpHint = isHelpHint
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { steerDraft, submitDraft } = useComposerSubmit({
|
||||
@@ -267,7 +286,7 @@ export function ChatBar({
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
@@ -332,7 +351,7 @@ export function ChatBar({
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
@@ -506,11 +525,11 @@ export function ChatBar({
|
||||
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
loadIntoComposer(entry, scope.attachments.$attachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
@@ -531,11 +550,11 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
loadIntoComposer(result.text, scope.attachments.$attachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +662,7 @@ export function ChatBar({
|
||||
useComposerBranch({ clearDraft, cwd, draftRef })
|
||||
|
||||
// Global Esc-to-cancel when the chat (not the composer input) has focus.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel })
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
|
||||
|
||||
const {
|
||||
conversation,
|
||||
@@ -663,7 +682,8 @@ export function ChatBar({
|
||||
maxRecordingSeconds,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
sessionId
|
||||
sessionId,
|
||||
target: scope.target
|
||||
})
|
||||
|
||||
const contextMenu = (
|
||||
@@ -685,7 +705,7 @@ export function ChatBar({
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
level: conversation.level,
|
||||
@@ -741,7 +761,7 @@ export function ChatBar({
|
||||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
onFocus={() => markActiveComposer(scope.target)}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
@@ -845,7 +865,7 @@ export function ChatBar({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{isHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
@@ -935,6 +955,10 @@ export function ChatBar({
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
{/* Contribution seams: banners above, a row below, inline
|
||||
additions beside the "+" menu and before the controls.
|
||||
All four render nothing until something contributes. */}
|
||||
<ContribSlot area={COMPOSER_AREAS.top} />
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
@@ -970,10 +994,17 @@ export function ChatBar({
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
|
||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||
{contextMenu}
|
||||
<ContribSlot area={COMPOSER_AREAS.leading} />
|
||||
</div>
|
||||
<div className="min-w-0 [grid-area:input]">{input}</div>
|
||||
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
|
||||
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
|
||||
<ContribSlot area={COMPOSER_AREAS.actions} />
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
<ContribSlot area={COMPOSER_AREAS.bottom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
@@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
||||
/** MIME for an in-app session drag (sidebar row → composer). */
|
||||
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
|
||||
|
||||
/** A dragged sidebar session — carried in-memory by the pointer drag session
|
||||
* (session-drag.ts); sessions never ride native DnD. */
|
||||
export interface SessionDragPayload {
|
||||
id: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
|
||||
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
|
||||
transfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function dragHasSession(transfer: DataTransfer | null) {
|
||||
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
|
||||
}
|
||||
|
||||
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
|
||||
const raw = transfer?.getData(HERMES_SESSION_MIME)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
/** A session's friendly display label — its title, or a localized fallback. */
|
||||
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
|
||||
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
|
||||
|
||||
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
|
||||
* needs to resolve the link (session_search); label shows the friendly title. */
|
||||
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
|
||||
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
|
||||
}
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { $messages } from '@/store/session'
|
||||
|
||||
import type { ComposerTarget } from './focus'
|
||||
|
||||
/**
|
||||
* COMPOSER SCOPE — which live composer a ChatBar instance IS. The main chat's
|
||||
* ChatBar runs in the default scope (module-level attachment atom, focus-bus
|
||||
* target 'main', the active session's awaiting-input edge). A session tile
|
||||
* mounts its ChatBar under its own scope, so N composers coexist: separate
|
||||
* attachment chips, separate focus/insert routing, separate Esc semantics.
|
||||
*
|
||||
* Draft TEXT needs no scoping — it lives in each ChatBar's contentEditable +
|
||||
* draftRef and stashes per session key (`stashSessionDraft`), which already
|
||||
* differs per surface.
|
||||
*/
|
||||
export interface ComposerScope {
|
||||
/** This scope's "turn parked on user input" edge — gates Esc-to-stop. */
|
||||
$awaitingInput: ReadableAtom<boolean>
|
||||
attachments: ComposerAttachmentScope
|
||||
/** Only the main scope may pop out (the floating composer is a singleton). */
|
||||
popoutAllowed: boolean
|
||||
/** Imperative read of this scope's transcript (input-history browse) —
|
||||
* never subscribed, so streaming stays out of the composer's renders. */
|
||||
readMessages: () => ChatMessage[]
|
||||
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
|
||||
$awaitingInput: $activeSessionAwaitingInput,
|
||||
attachments: mainComposerScope,
|
||||
popoutAllowed: true,
|
||||
readMessages: () => $messages.get(),
|
||||
target: 'main'
|
||||
}
|
||||
|
||||
const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
|
||||
|
||||
export const ComposerScopeProvider = ComposerScopeContext.Provider
|
||||
|
||||
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
|
||||
@@ -255,22 +255,46 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
|
||||
return { osDrops, inAppRefs }
|
||||
}
|
||||
|
||||
/** The composer these actions feed. Defaults to the main chat's scope;
|
||||
* session tiles pass their own so picks/drops/pastes land in THEIR chips. */
|
||||
interface ComposerActionsScope {
|
||||
add: (attachment: ComposerAttachment) => void
|
||||
remove: (id: string) => ComposerAttachment | null
|
||||
target: string
|
||||
}
|
||||
|
||||
const MAIN_ACTIONS_SCOPE: ComposerActionsScope = {
|
||||
add: addComposerAttachment,
|
||||
remove: removeComposerAttachment,
|
||||
target: 'main'
|
||||
}
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
currentCwd: string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
scope?: ComposerActionsScope
|
||||
}
|
||||
|
||||
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
|
||||
const attachToMain = (attachment: ComposerAttachment) => {
|
||||
addComposerAttachment(attachment)
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
export function useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway,
|
||||
scope = MAIN_ACTIONS_SCOPE
|
||||
}: ComposerActionsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
/** Add to this scope's composer and focus it. All sidebar/picker/drop
|
||||
* attach paths funnel through here. */
|
||||
const attachToMain = useCallback(
|
||||
(attachment: ComposerAttachment) => {
|
||||
scope.add(attachment)
|
||||
requestComposerFocus(scope.target)
|
||||
},
|
||||
[scope]
|
||||
)
|
||||
|
||||
const addTextToDraft = useCallback((text: string) => {
|
||||
requestComposerInsert(text, { mode: 'block' })
|
||||
}, [])
|
||||
@@ -302,7 +326,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
}, [])
|
||||
}, [attachToMain])
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
@@ -329,7 +353,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
})
|
||||
}
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const insertContextPathInlineRef = useCallback(
|
||||
@@ -344,12 +368,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
return false
|
||||
}
|
||||
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
requestComposerInsertRefs([ref], { target: scope.target })
|
||||
requestComposerFocus(scope.target)
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[currentCwd, scope.target]
|
||||
)
|
||||
|
||||
const attachContextFilePath = useCallback(
|
||||
@@ -371,7 +395,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const attachImagePath = useCallback(
|
||||
@@ -394,7 +418,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
const previewUrl = await attachmentPreviewDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
scope.add({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -404,7 +428,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
return true
|
||||
}
|
||||
},
|
||||
[copy.imagePreviewFailed]
|
||||
[attachToMain, copy.imagePreviewFailed, scope]
|
||||
)
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
@@ -509,7 +533,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
[attachToMain, currentCwd]
|
||||
)
|
||||
|
||||
const attachDroppedItems = useCallback(
|
||||
@@ -599,7 +623,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
async (id: string) => {
|
||||
const removed = removeComposerAttachment(id)
|
||||
const removed = scope.remove(id)
|
||||
|
||||
if (
|
||||
removed?.kind === 'image' &&
|
||||
@@ -614,7 +638,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
[activeSessionId, requestGateway, scope]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,53 +1,75 @@
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
dragHasAttachments,
|
||||
dragHasSession,
|
||||
readSessionDrag,
|
||||
type SessionDragPayload
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
/** `'session'` is set by callers from the pointer drag session's store —
|
||||
* native drags only ever resolve to `'files'` here (sessions left native
|
||||
* DnD; see session-drag.ts). */
|
||||
export type DragKind = 'files' | 'session' | null
|
||||
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind => {
|
||||
if (dragHasSession(event.dataTransfer)) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return 'files'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind =>
|
||||
dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
onDropSession?: (session: SessionDragPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||
* "Drop anywhere in this region" affordance for FILE drags — the one drag
|
||||
* kind still on native DnD (Finder/OS drops and the project tree must be).
|
||||
* An enter/leave depth counter keeps nested children from flickering the
|
||||
* active state; `onDropCapture` clears it even when a nested target (the
|
||||
* composer) handles the drop and stops propagation before our bubble-phase
|
||||
* `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
|
||||
* Esc aborts an in-flight drag, matching the sidebar session drag.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||
const depth = useRef(0)
|
||||
const aborted = useRef(false)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragKind(null)
|
||||
}, [])
|
||||
|
||||
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
|
||||
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
|
||||
// guard that swallows the trailing drop instead. Top escape layer + capture
|
||||
// stop so it doesn't also fire a handler behind the drag (see drag-session).
|
||||
useEffect(() => {
|
||||
if (dragKind === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
aborted.current = true
|
||||
reset()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [dragKind, reset])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
@@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
|
||||
if (depth.current === 0) {
|
||||
aborted.current = false
|
||||
}
|
||||
|
||||
depth.current += 1
|
||||
setDragKind(kind)
|
||||
},
|
||||
@@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
return
|
||||
}
|
||||
|
||||
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
|
||||
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
|
||||
// file drop in the capture phase, so that flag is always set here (every
|
||||
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
|
||||
// and never reach this bubble handler anyway.
|
||||
const claimed = aborted.current
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
if (kind === 'session') {
|
||||
const session = readSessionDrag(event.dataTransfer)
|
||||
|
||||
if (session) {
|
||||
onDropSession?.(session)
|
||||
}
|
||||
|
||||
if (claimed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
|
||||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, onDropSession, reset]
|
||||
[enabled, onDropFiles, reset]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage
|
||||
} from '@assistant-ui/react'
|
||||
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef } from 'react'
|
||||
import { Suspense, useCallback, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
coalesceToolOnlyAssistants,
|
||||
createToolMergeCache,
|
||||
quickModelOptions,
|
||||
sessionTitle,
|
||||
toRuntimeMessage
|
||||
} from '@/lib/chat-runtime'
|
||||
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -35,23 +25,14 @@ import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$contextSuggestions,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$resumeExhaustedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
|
||||
@@ -63,12 +44,15 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRefs } from './composer/inline-refs'
|
||||
import { useComposerScope } from './composer/scope'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { useRuntimeMessageRepository } from './runtime-repository'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { useSessionView } from './session-view'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
|
||||
@@ -122,7 +106,7 @@ function ChatHeader({
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
const activeStoredSession =
|
||||
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
|
||||
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
|
||||
@@ -160,14 +144,7 @@ function ChatHeader({
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
|
||||
</Button>
|
||||
<TitleMenuTrigger>{title}</TitleMenuTrigger>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</header>
|
||||
@@ -207,45 +184,9 @@ function ChatRuntimeBoundary({
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const storeMessages = useStore(useSessionView().$messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const toolMergeCacheRef = useRef(createToolMergeCache())
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
let visibleParentId: string | null = null
|
||||
let headId: string | null = null
|
||||
|
||||
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
|
||||
let parentId = visibleParentId
|
||||
|
||||
if (message.role === 'assistant' && message.branchGroupId) {
|
||||
if (!branchParentByGroup.has(message.branchGroupId)) {
|
||||
branchParentByGroup.set(message.branchGroupId, visibleParentId)
|
||||
}
|
||||
|
||||
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
|
||||
}
|
||||
|
||||
const cachedMessage = runtimeMessageCacheRef.current.get(message)
|
||||
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
|
||||
|
||||
if (!cachedMessage) {
|
||||
runtimeMessageCacheRef.current.set(message, runtimeMessage)
|
||||
}
|
||||
|
||||
items.push({ message: runtimeMessage, parentId })
|
||||
|
||||
if (!message.hidden) {
|
||||
visibleParentId = message.id
|
||||
headId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
@@ -293,13 +234,24 @@ export function ChatView({
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
// The view this surface renders: the primary route-driven session (global
|
||||
// atoms) or a tile's session slice — same component either way.
|
||||
const view = useSessionView()
|
||||
const composerScope = useComposerScope()
|
||||
const isPrimary = view.kind === 'primary'
|
||||
const activeSessionId = useStore(view.$runtimeId)
|
||||
const storedId = useStore(view.$storedId)
|
||||
// Dock anchor for a session drop onto this surface: the workspace pane for the
|
||||
// primary, this tile's pane id for a tile. Read by the session-drop bridge.
|
||||
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
|
||||
const awaitingResponse = useStore(view.$awaitingResponse)
|
||||
const busy = useStore(view.$busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
// Per-session (SessionView) reads — a tile IS its session, so these come
|
||||
// from the view slice, not the global atoms (which track the primary only).
|
||||
const currentCwd = useStore(view.$cwd)
|
||||
const currentModel = useStore(view.$model)
|
||||
const currentProvider = useStore(view.$provider)
|
||||
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
|
||||
const petActive = useStore($petActive)
|
||||
const petOverlayActive = useStore($petOverlayActive)
|
||||
@@ -310,16 +262,18 @@ export function ChatView({
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
|
||||
// every streaming delta flush (~30×/s) and a subscription here re-renders
|
||||
// the entire chat shell (header, chat bar, thread wrapper) per token. The
|
||||
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
|
||||
// this component only needs streaming-stable derivations.
|
||||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
// PERF: ChatView must not subscribe to the view's $messages — the atom is
|
||||
// replaced on every streaming delta flush (~30×/s) and a subscription here
|
||||
// re-renders the entire chat shell (header, chat bar, thread wrapper) per
|
||||
// token. The runtime that DOES need the messages lives in
|
||||
// ChatRuntimeBoundary below; this component only needs streaming-stable
|
||||
// derivations.
|
||||
const messagesEmpty = useStore(view.$messagesEmpty)
|
||||
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
|
||||
const selectedSessionId = useStore(view.$storedId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
// A tile IS its session — no route involved, never "mismatched".
|
||||
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
|
||||
@@ -331,6 +285,7 @@ export function ChatView({
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
isPrimary &&
|
||||
!isSecondaryWindow() &&
|
||||
freshDraftReady &&
|
||||
!isRoutedSessionView &&
|
||||
@@ -349,7 +304,7 @@ export function ChatView({
|
||||
// Suppress the loader and show an explicit error + manual Retry instead of
|
||||
// spinning forever. Gated on the route matching so a stale latch from another
|
||||
// session can't blank the current one.
|
||||
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||
const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||
|
||||
const loadingSession =
|
||||
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||
@@ -420,23 +375,33 @@ export function ChatView({
|
||||
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||
|
||||
if (refs.length) {
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target })
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
void onAttachDroppedItems(osDrops)
|
||||
}
|
||||
},
|
||||
[currentCwd, onAttachDroppedItems]
|
||||
[composerScope.target, currentCwd, onAttachDroppedItems]
|
||||
)
|
||||
|
||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||
// via session_search (carries the source profile, so cross-profile works).
|
||||
const onDropSession = useCallback((session: SessionDragPayload) => {
|
||||
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
|
||||
}, [])
|
||||
// Session drags are POINTER drags (session-drag.ts) — never native DnD.
|
||||
// The drop zone below only handles files; session drops commit through the
|
||||
// drag session itself, which routes a center/link drop to this surface's
|
||||
// composer via `data-composer-target`.
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
|
||||
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
|
||||
// While a session drag targets one of this surface's EDGES or a tab strip,
|
||||
// the zone overlay/caret owns the visual — the link overlay stands down.
|
||||
// It shows for the whole drag on every chat surface otherwise (the drag
|
||||
// session's global sentinel, not a per-surface hover chain).
|
||||
// COMPUTED booleans, never the raw `$dropHint`: the hint churns on every
|
||||
// pointer-crossing of every drag (pane drags included), and a re-render
|
||||
// here is the WHOLE surface — thread, composer, header — per mounted tile.
|
||||
const sessionDragging = useStore($sessionTileDragging)
|
||||
const sessionEdgeHover = useStore($sessionTileEdgeHover)
|
||||
|
||||
const overlayKind: DragKind = dragKind === 'files' ? 'files' : sessionDragging && !sessionEdgeHover ? 'session' : null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -444,17 +409,26 @@ export function ChatView({
|
||||
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
|
||||
className
|
||||
)}
|
||||
data-composer-target={composerScope.target}
|
||||
data-session-anchor={sessionAnchor}
|
||||
>
|
||||
<Backdrop />
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
onDeleteSelectedSession={onDeleteSelectedSession}
|
||||
onToggleSelectedPin={onToggleSelectedPin}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
{/* Tiles get their chrome from the layout zone (chip strip); the modal
|
||||
prompt overlays stay active-session-scoped in the primary surface. */}
|
||||
{isPrimary && (
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
onDeleteSelectedSession={onDeleteSelectedSession}
|
||||
onToggleSelectedPin={onToggleSelectedPin}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PromptOverlays />
|
||||
{/* Mounted for the primary AND every tile, each scoped to its own session
|
||||
so a tiled/background session's blocking prompt surfaces instead of
|
||||
stalling to timeout. */}
|
||||
<PromptOverlays sessionId={activeSessionId} />
|
||||
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
@@ -510,7 +484,9 @@ export function ChatView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
{/* A session drag hovering an EDGE hands the visual to the zone
|
||||
target; the link overlay shows only for the center region. */}
|
||||
<ChatDropOverlay kind={overlayKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
|
||||
* register a pane per tile, refresh its title in place, and dispose panes whose
|
||||
* tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set
|
||||
* diff, a one-time pane closer — behind BOTH session tiles and route (page)
|
||||
* tiles; each supplies only what differs (key, title, render, close, edge).
|
||||
*/
|
||||
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { TileDock } from '@/store/session-states'
|
||||
|
||||
export interface PaneMirror<T> {
|
||||
/** Reactive source list. */
|
||||
source: ReadableAtom<T[]>
|
||||
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
|
||||
also?: ReadableAtom<unknown>[]
|
||||
/** Stable key + pane-id seed for a tile. */
|
||||
key: (tile: T) => string
|
||||
/** Pane-id namespace — the id is `${prefix}:${key}`. */
|
||||
prefix: string
|
||||
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
|
||||
dir?: (tile: T) => TileDock | undefined
|
||||
/** Pane to dock against (default `workspace`) — a drop's target zone. */
|
||||
anchor?: (tile: T) => string | undefined
|
||||
/** Center docks: the strip slot (stack before this pane id). */
|
||||
before?: (tile: T) => null | string | undefined
|
||||
minWidth: string
|
||||
title: (key: string) => string
|
||||
render: (key: string) => ReactNode
|
||||
/** Wrap the tile's TAB (domain context menu — session verbs). */
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
/** Override the tile's TAB drag (session drop language: stack/split/link).
|
||||
* Returns whether it took the drag (see PaneChrome.tabDrag). */
|
||||
tabDrag?: (
|
||||
key: string,
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
onTap: () => void,
|
||||
double?: DoubleTapContext
|
||||
) => boolean
|
||||
/** Wired as the pane's closer (tab Close). */
|
||||
close: (key: string) => void
|
||||
}
|
||||
|
||||
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
|
||||
* Module-level state lives in the returned closure, so call it once per app. */
|
||||
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
const registered = new Map<string, { dispose: () => void; title: string }>()
|
||||
const paneId = (key: string) => `${cfg.prefix}:${key}`
|
||||
|
||||
const sync = () => {
|
||||
const tiles = cfg.source.get()
|
||||
const wanted = new Set(tiles.map(cfg.key))
|
||||
|
||||
for (const tile of tiles) {
|
||||
const key = cfg.key(tile)
|
||||
const title = cfg.title(key)
|
||||
const current = registered.get(key)
|
||||
|
||||
// register() replaces same-id in place — safe for live title refreshes.
|
||||
if (current && current.title === title) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dispose = registry.register({
|
||||
id: paneId(key),
|
||||
area: 'panes',
|
||||
title,
|
||||
data: {
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
pane: cfg.anchor?.(tile) ?? 'workspace',
|
||||
pos: cfg.dir?.(tile) ?? 'right'
|
||||
},
|
||||
minWidth: cfg.minWidth,
|
||||
placement: 'main',
|
||||
tabDrag: cfg.tabDrag
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
|
||||
cfg.tabDrag!(key, event, onTap, double)
|
||||
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
|
||||
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
|
||||
},
|
||||
render: () => cfg.render(key)
|
||||
})
|
||||
|
||||
registered.set(key, { dispose, title })
|
||||
|
||||
if (!current) {
|
||||
registerPaneCloser(paneId(key), () => cfg.close(key))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of registered) {
|
||||
if (!wanted.has(key)) {
|
||||
entry.dispose()
|
||||
registered.delete(key)
|
||||
removeTreePane(paneId(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Prune tree panes the SHARED tree persisted for a tile we never registered
|
||||
// this session and that isn't wanted now — a profile switch reloads with the
|
||||
// other profile's tile panes still stacked in. (`registered` is empty after a
|
||||
// reload, so the loop above can't catch these.)
|
||||
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
|
||||
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
|
||||
removeTreePane(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
sync()
|
||||
cfg.source.listen(sync)
|
||||
cfg.also?.forEach(atom => atom.listen(sync))
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview'
|
||||
|
||||
@@ -498,8 +498,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Insert into and focus the SAME composer — 'active' — so a tile that owns
|
||||
// focus keeps it instead of the ref landing in a tile but main stealing focus.
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
requestComposerFocus('active')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
@@ -38,14 +39,6 @@ import { PreviewPane } from './preview-pane'
|
||||
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
|
||||
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
|
||||
|
||||
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
|
||||
|
||||
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
|
||||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
// Subtracts the project browser width so preview yields rather than crushing
|
||||
// the chat when both right-side panes are open.
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
|
||||
|
||||
interface ChatPreviewRailProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
@@ -110,7 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
|
||||
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
className={cn(
|
||||
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
|
||||
PANE_TAB_STRIP_LINE
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
@@ -124,67 +122,20 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
return (
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<button
|
||||
<PaneTabLabel
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
as="button"
|
||||
className="normal-case tracking-normal"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
{tab.label}
|
||||
</PaneTabLabel>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
{dirty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
|
||||
>
|
||||
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
|
||||
legible where it overlaps the filename. */}
|
||||
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
</PaneTab>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ROUTE (PAGE) TILES — a full-page view rendered as a layout-tree pane BESIDE
|
||||
* the main thread, the page analog of session tiles. Built-in pages
|
||||
* (Capabilities / Messaging / Artifacts) render their view; plugin pages render
|
||||
* their `ROUTES_AREA` contribution. Lifecycle mirrors session tiles:
|
||||
* `openRouteTile(path)` -> `watchRouteTiles` registers a pane docked beside
|
||||
* main -> tree adoption lands it on the chosen edge; closing removes it.
|
||||
*/
|
||||
|
||||
import { lazy, type ReactNode, Suspense } from 'react'
|
||||
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
|
||||
|
||||
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
import { paneMirror } from './pane-mirror'
|
||||
|
||||
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
|
||||
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
||||
|
||||
// Built-in page views + their pane titles, keyed by route.
|
||||
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
|
||||
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
|
||||
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
|
||||
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
|
||||
}
|
||||
|
||||
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
|
||||
const humanizePath = (path: string): string =>
|
||||
path
|
||||
.replace(/^\/+/, '')
|
||||
.split(/[/-]/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ') || path
|
||||
|
||||
/** Title for a route tile: the built-in name, the contribution's own `title`,
|
||||
* else a humanized path — never the internal `${source}:${id}` key. */
|
||||
function routeTitle(path: string): string {
|
||||
if (BUILTIN_PAGES[path]) {
|
||||
return BUILTIN_PAGES[path].title
|
||||
}
|
||||
|
||||
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
|
||||
}
|
||||
|
||||
function RouteTilePane({ path }: { path: string }) {
|
||||
const builtin = BUILTIN_PAGES[path]
|
||||
|
||||
// Subscribe so a plugin page tile appears the moment its route registers.
|
||||
useContributions(ROUTES_AREA)
|
||||
const contrib = builtin ? null : contributedRoutes().find(r => r.path === path)
|
||||
|
||||
if (builtin) {
|
||||
return (
|
||||
<ContribBoundary id={path}>
|
||||
<Suspense fallback={null}>{builtin.render()}</Suspense>
|
||||
</ContribBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
if (contrib) {
|
||||
return <ContribBoundary id={path}>{contrib.render()}</ContribBoundary>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||
no page at {path}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
|
||||
export const watchRouteTiles = paneMirror<RouteTile>({
|
||||
source: $routeTiles,
|
||||
key: t => t.path,
|
||||
prefix: 'route-tile',
|
||||
dir: t => t.dir,
|
||||
minWidth: '22rem',
|
||||
title: routeTitle,
|
||||
render: path => <RouteTilePane path={path} />,
|
||||
close: closeRouteTile
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { coalesceToolOnlyAssistants, createToolMergeCache, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
|
||||
/**
|
||||
* ChatMessage[] -> assistant-ui message repository, with a WeakMap identity
|
||||
* cache so unchanged messages convert once (and a tool-merge cache that folds
|
||||
* tool-only assistant turns into their neighbour). Shared by the main chat's
|
||||
* runtime boundary and session tiles — one transcript pipeline, N surfaces.
|
||||
*/
|
||||
export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMessageRepository {
|
||||
const cacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const toolMergeCacheRef = useRef(createToolMergeCache())
|
||||
|
||||
return useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
let visibleParentId: string | null = null
|
||||
let headId: string | null = null
|
||||
|
||||
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
|
||||
let parentId = visibleParentId
|
||||
|
||||
if (message.role === 'assistant' && message.branchGroupId) {
|
||||
if (!branchParentByGroup.has(message.branchGroupId)) {
|
||||
branchParentByGroup.set(message.branchGroupId, visibleParentId)
|
||||
}
|
||||
|
||||
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
|
||||
}
|
||||
|
||||
const cachedMessage = cacheRef.current.get(message)
|
||||
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
|
||||
|
||||
if (!cachedMessage) {
|
||||
cacheRef.current.set(message, runtimeMessage)
|
||||
}
|
||||
|
||||
items.push({ message: runtimeMessage, parentId })
|
||||
|
||||
if (!message.hidden) {
|
||||
visibleParentId = message.id
|
||||
headId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Sidebar session drag — the session RESOLVER over the shared pointer drag
|
||||
* session (pane-shell drag-session.ts). Same machinery as a pane drag
|
||||
* (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous
|
||||
* teardown), session-specific targeting:
|
||||
*
|
||||
* - a chat zone's TAB STRIP → stack: open the session as a tab at the
|
||||
* divider's slot (the strip caret shows it);
|
||||
* - a chat zone's EDGE band → split: open the session as a tile docked on
|
||||
* that edge (the zone sheet morphs to the half);
|
||||
* - a chat zone's CENTER / the composer → link: insert an `@session` chip
|
||||
* into that surface's composer (ChatDropOverlay owns the visual);
|
||||
* - anything else (sidebar, terminal, gutters) → deny.
|
||||
*
|
||||
* Zones that don't host a chat surface are NOT targets — the overlay never
|
||||
* lights them, so a release there must not commit either (one truth).
|
||||
*
|
||||
* This replaced the native-HTML5 drag + SessionTileDropBridge: riding the
|
||||
* native DnD layer meant macOS's cancel snap-back animation, a `dragend`
|
||||
* held hostage until that animation finished, an Esc the page never even
|
||||
* saw, and window-level armor against react-dnd/dnd-kit. A pointer session
|
||||
* has none of those failure modes. Native DnD remains only at the true OS
|
||||
* boundary (Finder file drops). Known trade: a session can no longer be
|
||||
* dragged into a separate BrowserWindow (native DnD was the only transport
|
||||
* that crossed windows).
|
||||
*/
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
type DoubleTapContext,
|
||||
rectContains,
|
||||
slotBefore,
|
||||
snapshotStrips,
|
||||
snapshotZones,
|
||||
startDragSession,
|
||||
type StripSnapshot,
|
||||
subZonePosition
|
||||
} from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
$treeDragging,
|
||||
type DropHint,
|
||||
revealTreePane,
|
||||
SESSION_TILE_DRAG
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine'
|
||||
import { openSessionTile, type TileDock } from '@/store/session-states'
|
||||
|
||||
import { requestComposerInsertRefs } from './composer/focus'
|
||||
import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs'
|
||||
|
||||
/** A chat surface's drag-start geometry: the anchor pane id it advertises
|
||||
* (`data-session-anchor`) and the composer a link drop routes to
|
||||
* (`data-composer-target`). */
|
||||
interface SurfaceSnapshot {
|
||||
anchor: string
|
||||
composerTarget: string
|
||||
rect: ZoneRect
|
||||
}
|
||||
|
||||
const snapRect = (el: HTMLElement): ZoneRect => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
||||
}
|
||||
|
||||
function snapshotSurfaces(): SurfaceSnapshot[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({
|
||||
anchor: el.dataset.sessionAnchor || 'workspace',
|
||||
composerTarget: el.dataset.composerTarget || 'main',
|
||||
rect: snapRect(el)
|
||||
}))
|
||||
}
|
||||
|
||||
/** A session may land in a zone only if it hosts a chat surface — never the
|
||||
* sidebar/terminal zones. Returns the pane a stack anchors to. */
|
||||
function chatZonePane(groupId: string): null | string {
|
||||
const tree = $layoutTree.get()
|
||||
const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : []
|
||||
|
||||
return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin dragging a session — a sidebar row OR a tile's own tab (same drop
|
||||
* language either way: stack, split, or composer link). Sub-threshold releases
|
||||
* stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double`
|
||||
* (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/
|
||||
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
|
||||
* row and MOVES the existing one when its tab is the drag source.
|
||||
*/
|
||||
export function startSessionDrag(
|
||||
payload: SessionDragPayload,
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
opts?: { double?: DoubleTapContext; onTap?: () => void }
|
||||
) {
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
let surfaces: SurfaceSnapshot[] = []
|
||||
let composers: ZoneRect[] = []
|
||||
let zoneHost = new Map<string, null | string>()
|
||||
|
||||
// Commit intent, updated per resolved move (the machinery flushes the final
|
||||
// move before commit, so these always match the released-at position).
|
||||
let split: { anchor: string; before?: null | string; pos: TileDock } | null = null
|
||||
let link: null | string = null
|
||||
|
||||
// The drag SOURCE (sidebar row or tile tab). Captured synchronously — React
|
||||
// clears `currentTarget` after the pointerdown handler returns, but this runs
|
||||
// inside it. Dimmed while lifted so the source reads as "picked up" — the
|
||||
// same in-place feedback pane-tab drags use, replacing the old cursor chip.
|
||||
const source = e.currentTarget
|
||||
const restoreOpacity = source?.style.opacity ?? ''
|
||||
|
||||
startDragSession(e, {
|
||||
double: opts?.double,
|
||||
ghost: { label: sessionLabel(payload) },
|
||||
onTap: opts?.onTap,
|
||||
|
||||
onEngage() {
|
||||
zones = snapshotZones()
|
||||
strips = snapshotStrips()
|
||||
surfaces = snapshotSurfaces()
|
||||
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
|
||||
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)]))
|
||||
source?.style.setProperty('opacity', '0.45')
|
||||
// The same sentinel the zone overlay + chat surfaces key off — the
|
||||
// whole drop language (sheets, pills, caret, link overlay) lights up.
|
||||
$treeDragging.set(SESSION_TILE_DRAG)
|
||||
},
|
||||
|
||||
onEnd() {
|
||||
if (source) {
|
||||
source.style.opacity = restoreOpacity
|
||||
}
|
||||
},
|
||||
|
||||
resolveMove(x, y): DropHint | null {
|
||||
const zone = zones.find(z => rectContains(z.rect, x, y))
|
||||
const host = zone ? zoneHost.get(zone.id) : null
|
||||
|
||||
if (!zone || !host) {
|
||||
split = null
|
||||
link = null
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// The zone's TAB STRIP stacks the session at the divider's slot.
|
||||
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
|
||||
|
||||
if (strip) {
|
||||
// Exclude the tile's OWN tab from the slots so re-dropping it in its
|
||||
// home strip reorders cleanly (a no-op for a sidebar-row drag).
|
||||
const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`)
|
||||
split = { anchor: host, before: stack.before, pos: 'center' }
|
||||
link = null
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack }
|
||||
}
|
||||
|
||||
// The composer (and everything in it) is always the link/attach drop;
|
||||
// elsewhere the shared radial targeting decides center vs edge.
|
||||
const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y)
|
||||
const surface = surfaces.find(s => rectContains(s.rect, x, y))
|
||||
|
||||
if (pos === 'center') {
|
||||
split = null
|
||||
link = surface?.composerTarget ?? 'main'
|
||||
} else {
|
||||
split = { anchor: surface?.anchor ?? 'workspace', pos }
|
||||
link = null
|
||||
}
|
||||
|
||||
return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos }
|
||||
},
|
||||
|
||||
onCommit() {
|
||||
if (split) {
|
||||
openSessionTile(payload.id, split.pos, split.anchor, split.before)
|
||||
// A tile for this session may already exist (openSessionTile is
|
||||
// idempotent — e.g. persisted from an earlier run): a drop must never
|
||||
// feel dead, so front/unhide/un-dismiss it either way.
|
||||
revealTreePane(`session-tile:${payload.id}`)
|
||||
} else if (link) {
|
||||
// The "link to chat" drop: an @session chip in that surface's composer.
|
||||
requestComposerInsertRefs([sessionInlineRef(payload)], { target: link })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Prompt actions for a SESSION TILE — the same verbs the primary chat wires
|
||||
* (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide
|
||||
* sync), targeted at the tile's session instead of the active one. State
|
||||
* writes go through the delegate's `updateSession` (the wiring cache), so
|
||||
* the cache, the primary view, and every tile mirror stay one truth; view
|
||||
* concerns (busy pill, transcript) reach the tile via its `$sessionStates`
|
||||
* slice — never the global `$busy`/`$messages`.
|
||||
*/
|
||||
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import { $connection } from '@/store/session'
|
||||
import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
applyReloadOptimistic,
|
||||
applyRewindOptimistic,
|
||||
finalizeInterruptedMessages,
|
||||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
} from '../session/hooks/use-prompt-actions/rewind'
|
||||
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
|
||||
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
|
||||
|
||||
import type { ComposerScope } from './composer/scope'
|
||||
|
||||
interface SessionTileActionsArgs {
|
||||
runtimeId: string
|
||||
scope: ComposerScope
|
||||
storedSessionId: string
|
||||
}
|
||||
|
||||
export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const runtimeIdRef = useRef(runtimeId)
|
||||
runtimeIdRef.current = runtimeId
|
||||
const storedIdRef = useRef(storedSessionId)
|
||||
storedIdRef.current = storedSessionId
|
||||
|
||||
// Tile busy tracks the SESSION state, never the global $busy — and it must
|
||||
// read LIVE. A render-time snapshot goes stale (this hook's host doesn't
|
||||
// re-render on busy edges), and a stale `true` silently blocks every
|
||||
// subsequent submit ("tile only sends one message"). The setter is a no-op:
|
||||
// session state owns busy; submit's optimistic writes flow through
|
||||
// updateSession.
|
||||
const busyRef = useMemo(
|
||||
() =>
|
||||
({
|
||||
get current() {
|
||||
return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false
|
||||
},
|
||||
set current(_value: boolean) {
|
||||
// Owned by session state.
|
||||
}
|
||||
}) as { current: boolean },
|
||||
[]
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
(updater: (state: ClientSessionState) => ClientSessionState) =>
|
||||
sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater),
|
||||
[]
|
||||
)
|
||||
|
||||
const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], [])
|
||||
const readMessages = useCallback(() => readState()?.messages ?? [], [readState])
|
||||
|
||||
// Tile-side attachment staging: same upload rules as the primary submit
|
||||
// (skip synced/pathless, byte-upload files+images), against the tile scope.
|
||||
const syncAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
attachments: ComposerAttachment[],
|
||||
options: { updateComposerAttachments?: boolean } = {}
|
||||
): Promise<ComposerAttachment[]> => {
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
const synced: ComposerAttachment[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||
synced.push(attachment)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
|
||||
if (options.updateComposerAttachments ?? true) {
|
||||
scope.attachments.update(next)
|
||||
}
|
||||
|
||||
synced.push(next)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
synced.push(attachment)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
[requestGateway, scope.attachments]
|
||||
)
|
||||
|
||||
// The REAL submit pipeline with tile seams: session always exists, and the
|
||||
// scope's writers replace the global view/attachment writes.
|
||||
const submitPromptText = useSubmitPrompt({
|
||||
activeSessionId: runtimeId,
|
||||
activeSessionIdRef: runtimeIdRef,
|
||||
busyRef,
|
||||
copy,
|
||||
createBackendSessionForSend: async () => runtimeIdRef.current,
|
||||
// A tile IS its session — no route to abandon, so the create-abort guard's
|
||||
// token is a stable constant (the guard never trips for a tile).
|
||||
getRouteToken: () => runtimeId,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef: storedIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
|
||||
scope: {
|
||||
clearAttachments: scope.attachments.clear,
|
||||
readAttachments: () => scope.attachments.$attachments.get(),
|
||||
// Busy/messages flow through updateSession -> the tile's state slice;
|
||||
// the primary view atoms must never see a tile turn.
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: () => undefined
|
||||
}
|
||||
})
|
||||
|
||||
const submitText = useCallback(
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const attachments = options?.attachments ?? scope.attachments.$attachments.get()
|
||||
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
||||
triggerHaptic('selection')
|
||||
await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return await submitPromptText(rawText, options)
|
||||
},
|
||||
[scope.attachments.$attachments, submitPromptText]
|
||||
)
|
||||
|
||||
const appendSystemNote = useCallback(
|
||||
(text: string) => {
|
||||
update(state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
|
||||
]
|
||||
}))
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = runtimeIdRef.current
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
messages: finalizeInterruptedMessages(state.messages, state.streamId),
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
needsInput: false,
|
||||
interrupted: true
|
||||
}))
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.stopFailed)
|
||||
}
|
||||
}, [copy.stopFailed, requestGateway, update])
|
||||
|
||||
const steerPrompt = useCallback(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = rawText.trim()
|
||||
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ status?: string }>('session.steer', {
|
||||
session_id: runtimeIdRef.current,
|
||||
text
|
||||
})
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
appendSystemNote(`steer:${text}`)
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[appendSystemNote, requestGateway]
|
||||
)
|
||||
|
||||
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
|
||||
// the primary chat so the two can't diverge.
|
||||
const submitRewind = useCallback(
|
||||
(text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
|
||||
runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst),
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const state = readState()
|
||||
|
||||
if (!state || state.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
const plan = planReload(state.messages, parentId)
|
||||
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
update(current => applyReloadOptimistic(current, plan))
|
||||
|
||||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
update(current => ({ ...current, busy: false, awaitingResponse: false }))
|
||||
notifyError(err, copy.regenerateFailed)
|
||||
}
|
||||
},
|
||||
[copy.regenerateFailed, readState, requestGateway, update]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => {
|
||||
const sessionId = runtimeIdRef.current
|
||||
const messages = readMessages()
|
||||
const plan = planRestore(messages, messageId, target)
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
const wasBusy = readState()?.busy ?? false
|
||||
|
||||
update(state => applyRewindOptimistic(state, plan.sourceIndex))
|
||||
|
||||
try {
|
||||
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
|
||||
} catch (err) {
|
||||
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[readMessages, readState, submitRewind, update]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const messages = readMessages()
|
||||
const plan = planEdit(messages, edited)
|
||||
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = runtimeIdRef.current
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
const wasBusy = readState()?.busy ?? false
|
||||
|
||||
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
|
||||
|
||||
try {
|
||||
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy)
|
||||
} catch (err) {
|
||||
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
notifyError(err, copy.editFailed)
|
||||
}
|
||||
},
|
||||
[copy.editFailed, readMessages, readState, submitRewind, update]
|
||||
)
|
||||
|
||||
// Branch-visibility sync (assistant-ui hides non-active branches).
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)),
|
||||
[update]
|
||||
)
|
||||
|
||||
const dismissError = useCallback(
|
||||
(messageId: string) => {
|
||||
update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) }))
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cancelRun,
|
||||
dismissError,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText
|
||||
}),
|
||||
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the
|
||||
* main thread (multi-session tiling). A tile IS the real chat surface: the
|
||||
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
|
||||
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
|
||||
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
|
||||
* (submit/slash/steer/edit/reload/restore/stop) come from
|
||||
* `useSessionTileActions`, all writing through the wiring cache.
|
||||
*
|
||||
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
|
||||
* pane contribution docked right of the main zone -> tree adoption lands it
|
||||
* -> the pane mounts and asks the delegate for a live runtime id. Closing
|
||||
* the pane (tab Close) removes the tile + its zone; tiles persist across
|
||||
* restarts and re-resume on boot.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import {
|
||||
$sessionStates,
|
||||
$sessionTiles,
|
||||
closeSessionTile,
|
||||
discardSessionTile,
|
||||
patchSessionTile,
|
||||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
|
||||
import type { SessionDragPayload } from './composer/inline-refs'
|
||||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
import { useComposerActions } from './hooks/use-composer-actions'
|
||||
import { paneMirror } from './pane-mirror'
|
||||
import { startSessionDrag } from './session-drag'
|
||||
import { useSessionTileActions } from './session-tile-actions'
|
||||
import { type SessionView, SessionViewProvider } from './session-view'
|
||||
import { SessionContextMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser } from './thread-loading'
|
||||
|
||||
import { ChatView } from '.'
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/** The tile's SessionView: the same atom shape the primary chat renders
|
||||
* from, computed from this session's slice of `$sessionStates`. */
|
||||
function buildTileView(storedSessionId: string): SessionView {
|
||||
const $runtimeId = computed(
|
||||
$sessionTiles,
|
||||
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
|
||||
)
|
||||
|
||||
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
|
||||
runtimeId ? states[runtimeId] : undefined
|
||||
)
|
||||
|
||||
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
|
||||
|
||||
return {
|
||||
kind: 'tile',
|
||||
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
|
||||
$busy: computed($state, state => Boolean(state?.busy)),
|
||||
$cwd: computed($state, state => state?.cwd ?? ''),
|
||||
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
|
||||
$messages,
|
||||
$messagesEmpty: computed($messages, messages => messages.length === 0),
|
||||
$model: computed($state, state => state?.model ?? ''),
|
||||
$provider: computed($state, state => state?.provider ?? ''),
|
||||
$runtimeId,
|
||||
// Constant for the tile's lifetime — a plain atom, not a computed.
|
||||
$storedId: atom(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function TileChat({
|
||||
runtimeId,
|
||||
storedSessionId,
|
||||
view
|
||||
}: {
|
||||
runtimeId: string
|
||||
storedSessionId: string
|
||||
view: SessionView
|
||||
}) {
|
||||
const { gatewayRef, requestGateway } = useGatewayRequest()
|
||||
const cwd = useStore(view.$cwd)
|
||||
|
||||
// One attachment set + focus key per tile, stable for the tile's lifetime.
|
||||
const attachments = useRef(createComposerAttachmentScope()).current
|
||||
|
||||
const scope = useMemo<ComposerScope>(
|
||||
() => ({
|
||||
$awaitingInput: sessionAwaitingInput(runtimeId),
|
||||
attachments,
|
||||
popoutAllowed: false,
|
||||
readMessages: () => view.$messages.get(),
|
||||
target: `tile:${storedSessionId}`
|
||||
}),
|
||||
[attachments, runtimeId, storedSessionId, view.$messages]
|
||||
)
|
||||
|
||||
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
|
||||
|
||||
// The same attach/pick/paste/drop pipeline the primary composer uses,
|
||||
// pointed at this tile's chips + session.
|
||||
const composer = useComposerActions({
|
||||
activeSessionId: runtimeId,
|
||||
currentCwd: cwd,
|
||||
requestGateway,
|
||||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionViewProvider value={view}>
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onBranchInNewChat={() => undefined}
|
||||
onCancel={actions.cancelRun}
|
||||
onDeleteSelectedSession={() => undefined}
|
||||
onDismissError={actions.dismissError}
|
||||
onEdit={actions.editMessage}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={actions.reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={actions.restoreToMessage}
|
||||
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
|
||||
onSteer={actions.steerPrompt}
|
||||
onSubmit={actions.submitText}
|
||||
onThreadMessagesChange={actions.handleThreadMessagesChange}
|
||||
onToggleSelectedPin={() => undefined}
|
||||
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
|
||||
/>
|
||||
</ComposerScopeProvider>
|
||||
</SessionViewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
|
||||
const tiles = useStore($sessionTiles)
|
||||
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
|
||||
const runtimeId = tile?.runtimeId ?? null
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const resumingRef = useRef(false)
|
||||
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
|
||||
|
||||
// Same gating as the primary's route resume (use-route-resume): never fire
|
||||
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
|
||||
// while it's still connecting — an ungated resume rejected there and
|
||||
// latched every restored tile into the error card.
|
||||
useEffect(() => {
|
||||
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const delegate = sessionTileDelegate()
|
||||
|
||||
if (!delegate) {
|
||||
return
|
||||
}
|
||||
|
||||
resumingRef.current = true
|
||||
|
||||
delegate
|
||||
.resumeTile(storedSessionId)
|
||||
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// A gone session (404 / "Session not found") is terminal — a stale or
|
||||
// cross-profile persisted tile. Discard it instead of latching an error
|
||||
// that re-retries on every reconnect (the "Session not found" spam).
|
||||
if (/session not found|\b404\b/i.test(message)) {
|
||||
discardSessionTile(storedSessionId)
|
||||
} else {
|
||||
patchSessionTile(storedSessionId, { error: message })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resumingRef.current = false
|
||||
})
|
||||
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
|
||||
|
||||
// The gateway (re)opening invalidates any latched error — it likely came
|
||||
// from a not-yet-open gateway or the previous connection. Clearing it
|
||||
// retriggers the resume effect: one bounded auto-retry per (re)connect,
|
||||
// mirroring the primary path's became-open resync.
|
||||
useEffect(() => {
|
||||
if (gatewayOpen && tile?.error) {
|
||||
patchSessionTile(storedSessionId, { error: undefined })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewayOpen, storedSessionId])
|
||||
|
||||
if (tile?.error) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
|
||||
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
|
||||
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
|
||||
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!runtimeId) {
|
||||
// The SAME session loader the primary thread shows (Thread's
|
||||
// loading === 'session' branch) — one loading language everywhere.
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<CenteredThreadSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
return stored ? sessionTitle(stored) : 'Session'
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
|
||||
// input) doesn't close silently.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stored id awaiting close confirmation (null = no dialog). */
|
||||
const $confirmCloseTile = atom<null | string>(null)
|
||||
|
||||
/** The tile closer, gated: a quiet session closes immediately; a busy or
|
||||
* input-blocked one asks first. One state read — the tile's runtime slice. */
|
||||
export function requestCloseSessionTile(storedSessionId: string): void {
|
||||
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
|
||||
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
|
||||
|
||||
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
|
||||
$confirmCloseTile.set(storedSessionId)
|
||||
} else {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
|
||||
export function SessionTileCloseConfirm() {
|
||||
const { t } = useI18n()
|
||||
const storedSessionId = useStore($confirmCloseTile)
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel={t.zones.closeRunningConfirm}
|
||||
description={t.zones.closeRunningBody}
|
||||
destructive
|
||||
onClose={() => $confirmCloseTile.set(null)}
|
||||
onConfirm={() => {
|
||||
if (storedSessionId) {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
}}
|
||||
open={storedSessionId !== null}
|
||||
title={t.zones.closeRunningTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Layout reset → every session tile collapses into the MAIN zone as a tab
|
||||
* after the workspace (the primary session stays the first tab), the "smart"
|
||||
* reset: N scattered tiles become one tab bar over the chat instead of
|
||||
* re-docking to their old edges.
|
||||
*
|
||||
* Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles
|
||||
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
|
||||
* workspace group as a tab (append). The main group id is re-read each pass
|
||||
* because appending returns a new tree. */
|
||||
export function stackSessionTilesIntoMain(): void {
|
||||
for (const tile of $sessionTiles.get()) {
|
||||
const tree = $layoutTree.get()
|
||||
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
|
||||
|
||||
if (mainGroup) {
|
||||
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A session TAB's context menu: the full session verb set (pin, copy id, new
|
||||
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
|
||||
* gets, targeted through the tile delegate (whose verbs are generic over
|
||||
* stored ids, primary included). The wrapper stops the contextmenu from also
|
||||
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
|
||||
export function SessionTabMenu({
|
||||
children,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
storedSessionId,
|
||||
tabPaneId
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
/** Close this tab (tiles; the main tab passes nothing). */
|
||||
onClose?: () => void
|
||||
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
|
||||
onHideTabBar?: () => void
|
||||
storedSessionId: string
|
||||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
return (
|
||||
<span className="contents" onContextMenu={event => event.stopPropagation()}>
|
||||
<SessionContextMenu
|
||||
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
|
||||
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
|
||||
onClose={onClose}
|
||||
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
|
||||
onHideTabBar={onHideTabBar}
|
||||
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
pinned={pinned}
|
||||
profile={stored?.profile}
|
||||
sessionId={storedSessionId}
|
||||
surface="tab"
|
||||
tabPaneId={tabPaneId}
|
||||
title={tileTitle(storedSessionId)}
|
||||
>
|
||||
{children}
|
||||
</SessionContextMenu>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
|
||||
* session, plus the bar's off switch (the bar sticky-shows once a tab is
|
||||
* ever gained; this is the explicit way back). A fresh draft has no session —
|
||||
* no menu. */
|
||||
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
|
||||
const selected = useStore($selectedStoredSessionId)
|
||||
|
||||
const hideTabBar = () => {
|
||||
const tree = $layoutTree.get()
|
||||
const group = tree ? findGroupOfPane(tree, 'workspace') : null
|
||||
|
||||
if (group) {
|
||||
setTreeGroupHeaderHidden(group.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
|
||||
{children}
|
||||
</SessionTabMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
|
||||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
anchor: t => t.anchor,
|
||||
before: t => t.before,
|
||||
minWidth: '20rem',
|
||||
title: tileTitle,
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
onClose={() => requestCloseSessionTile(storedSessionId)}
|
||||
storedSessionId={storedSessionId}
|
||||
tabPaneId={`session-tile:${storedSessionId}`}
|
||||
>
|
||||
{tab}
|
||||
</SessionTabMenu>
|
||||
),
|
||||
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
|
||||
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
|
||||
tabDrag: (storedSessionId, event, onTap, double) => {
|
||||
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
|
||||
|
||||
return true
|
||||
},
|
||||
close: requestCloseSessionTile
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId
|
||||
} from '@/store/session'
|
||||
|
||||
/**
|
||||
* SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view
|
||||
* is the app's classic global atoms (route-driven active session, untouched
|
||||
* fast path). A session TILE provides the same shape computed from its
|
||||
* session's slice of `$sessionStates`, so the identical ChatView tree renders
|
||||
* either — one chat surface, N sessions on screen.
|
||||
*
|
||||
* Everything is atoms (not values) so subscription granularity survives:
|
||||
* ChatView subscribes only to the coarse edges; `$messages` stays boundary-
|
||||
* only exactly like the primary view's perf contract.
|
||||
*/
|
||||
export interface SessionView {
|
||||
kind: 'primary' | 'tile'
|
||||
$runtimeId: ReadableAtom<string | null>
|
||||
$storedId: ReadableAtom<string | null>
|
||||
$messages: ReadableAtom<ChatMessage[]>
|
||||
$busy: ReadableAtom<boolean>
|
||||
$awaitingResponse: ReadableAtom<boolean>
|
||||
$messagesEmpty: ReadableAtom<boolean>
|
||||
$lastVisibleIsUser: ReadableAtom<boolean>
|
||||
$cwd: ReadableAtom<string>
|
||||
$model: ReadableAtom<string>
|
||||
$provider: ReadableAtom<string>
|
||||
}
|
||||
|
||||
export const PRIMARY_SESSION_VIEW: SessionView = {
|
||||
kind: 'primary',
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$cwd: $currentCwd,
|
||||
$lastVisibleIsUser: $lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$model: $currentModel,
|
||||
$provider: $currentProvider,
|
||||
$runtimeId: $activeSessionId,
|
||||
$storedId: $selectedStoredSessionId
|
||||
}
|
||||
|
||||
const SessionViewContext = createContext<SessionView>(PRIMARY_SESSION_VIEW)
|
||||
|
||||
export const SessionViewProvider = SessionViewContext.Provider
|
||||
|
||||
export const useSessionView = (): SessionView => useContext(SessionViewContext)
|
||||
@@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
@@ -34,8 +37,6 @@ import {
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarMessagingOpenIds,
|
||||
$sidebarOpen,
|
||||
$sidebarOverlayMounted,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarProjectOrderIds,
|
||||
$sidebarRecentsOpen,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
refreshWorktrees,
|
||||
scanAndRecordRepos
|
||||
} from '@/store/projects'
|
||||
import { openRouteTile } from '@/store/route-tiles'
|
||||
import {
|
||||
$cronSessions,
|
||||
$currentCwd,
|
||||
@@ -85,7 +87,6 @@ import {
|
||||
$messagingPlatformTotals,
|
||||
$messagingSessions,
|
||||
$messagingTruncated,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
@@ -94,8 +95,16 @@ import {
|
||||
sessionPinId,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import {
|
||||
type AppView,
|
||||
ARTIFACTS_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
SIDEBAR_NAV_AREA,
|
||||
type SidebarNavContribution,
|
||||
SKILLS_ROUTE
|
||||
} from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { countLabel } from './chrome'
|
||||
@@ -121,6 +130,7 @@ import {
|
||||
} from './projects'
|
||||
import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states'
|
||||
import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section'
|
||||
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
||||
|
||||
// Non-session groups (messaging platforms) stay compact: show a few rows up
|
||||
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||
@@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onBranchSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
/** Create a brand-new session and open it as a tile on `dir`. */
|
||||
onNewSessionSplit: (dir: SplitDir) => void
|
||||
onManageCronJob: (jobId: string) => void
|
||||
onTriggerCronJob: (jobId: string) => void
|
||||
}
|
||||
@@ -224,22 +236,49 @@ export function ChatSidebar({
|
||||
onArchiveSession,
|
||||
onBranchSession,
|
||||
onNewSessionInWorkspace,
|
||||
onNewSessionSplit,
|
||||
onManageCronJob,
|
||||
onTriggerCronJob
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
|
||||
const overlayMounted = useStore($sidebarOverlayMounted)
|
||||
const contentVisible = sidebarOpen || overlayMounted
|
||||
const { pathname } = useLocation()
|
||||
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
|
||||
// below the built-ins with the same chrome; active = at their route.
|
||||
const navContributions = useContributions(SIDEBAR_NAV_AREA)
|
||||
|
||||
const contributedNav = useMemo<SidebarNavItem[]>(
|
||||
() =>
|
||||
navContributions.flatMap(c => {
|
||||
const data = c.data as Partial<SidebarNavContribution> | undefined
|
||||
|
||||
if (!data?.path?.startsWith('/') || !data.label) {
|
||||
return []
|
||||
}
|
||||
|
||||
const codicon = data.codicon || 'plug'
|
||||
|
||||
return [
|
||||
{
|
||||
id: c.id,
|
||||
label: data.label,
|
||||
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
|
||||
route: data.path
|
||||
}
|
||||
]
|
||||
}),
|
||||
[navContributions]
|
||||
)
|
||||
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const cronOpen = useStore($sidebarCronOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
|
||||
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
|
||||
const selectedSessionId = useStore($focusedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
@@ -1031,15 +1070,12 @@ export function ChatSidebar({
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
// Visibility is the layout tree's job (a hidden zone is display:none;
|
||||
// the narrow overlay renders the live instance) — the sidebar always
|
||||
// paints itself fully.
|
||||
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
|
||||
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0',
|
||||
// While floated by PaneShell's hover-reveal, force visible + interactive
|
||||
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
|
||||
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
|
||||
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
@@ -1047,62 +1083,85 @@ export function ChatSidebar({
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
(item.id === 'artifacts' && currentView === 'artifacts') ||
|
||||
// Contributed rows light up at their own route.
|
||||
(Boolean(item.route) && pathname === item.route)
|
||||
|
||||
const isNewSession = item.id === 'new-session'
|
||||
|
||||
const button = (
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
)
|
||||
|
||||
// New session + route-backed pages can open in a split —
|
||||
// right-click for the directional "Open in split" submenu.
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{contentVisible && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{isNewSession || item.route ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
|
||||
<SplitSubmenu
|
||||
kit={CONTEXT_SPLIT_KIT}
|
||||
label={s.row.openInSplit}
|
||||
onSplit={dir => {
|
||||
if (isNewSession) {
|
||||
onNewSessionSplit(dir)
|
||||
} else if (item.route) {
|
||||
openRouteTile(item.route, dir)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
@@ -1110,7 +1169,7 @@ export function ChatSidebar({
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
@@ -1122,7 +1181,7 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
{showSessionSections && (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
@@ -1392,13 +1451,11 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<ProjectDialog />
|
||||
</Sidebar>
|
||||
|
||||
@@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import {
|
||||
REORDER_DRAG_TRANSITION_CSS,
|
||||
REORDER_RAIL_TRANSITION,
|
||||
reorderCommitHaptic,
|
||||
reorderStepHaptic
|
||||
} from '@/lib/reorder'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
// select. Drag-reorder and long-press-recolor live only on the squares path.
|
||||
const PROFILE_DROPDOWN_THRESHOLD = 13
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
|
||||
// glides between snapped cells on the snappier DRAG_TRANSITION.
|
||||
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
|
||||
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
|
||||
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
|
||||
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
|
||||
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
|
||||
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
|
||||
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
|
||||
|
||||
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
|
||||
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
|
||||
@@ -174,7 +179,7 @@ export function ProfileRail() {
|
||||
|
||||
if (id && id !== lastOverRef.current) {
|
||||
lastOverRef.current = id
|
||||
triggerHaptic('selection')
|
||||
reorderStepHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +196,7 @@ export function ProfileRail() {
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
triggerHaptic('success')
|
||||
reorderCommitHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
|
||||
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
|
||||
// immediately), pulled in transitively via session-states.
|
||||
setApiRequestProfile: () => {},
|
||||
HermesGateway: class {}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,7 +21,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
|
||||
import { $sessionTiles, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import type { SessionTitleResponse } from '../../types'
|
||||
@@ -80,10 +95,31 @@ interface SessionActions {
|
||||
onBranch?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
/** Close this surface (a tile tab) — omitted where nothing closes (sidebar
|
||||
* rows, the main tab). */
|
||||
onClose?: () => void
|
||||
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
|
||||
* nonsense there — sidebar rows/dropdowns keep it. */
|
||||
surface?: 'row' | 'tab'
|
||||
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) — enables
|
||||
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
|
||||
tabPaneId?: string
|
||||
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
|
||||
* once a tab is ever gained; this is the explicit off switch). */
|
||||
onHideTabBar?: () => void
|
||||
}
|
||||
|
||||
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
|
||||
/** A menu flavour (dropdown / context) — item + separator components. */
|
||||
interface MenuKit {
|
||||
Item: MenuItem
|
||||
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
|
||||
}
|
||||
|
||||
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
|
||||
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
|
||||
|
||||
interface ItemSpec {
|
||||
className?: string
|
||||
disabled: boolean
|
||||
@@ -101,26 +137,45 @@ function useSessionActions({
|
||||
onPin,
|
||||
onBranch,
|
||||
onArchive,
|
||||
onDelete
|
||||
onDelete,
|
||||
onClose,
|
||||
onHideTabBar,
|
||||
surface = 'row',
|
||||
tabPaneId
|
||||
}: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const tiles = useStore($sessionTiles)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
|
||||
// a tab): offering "Open in new tab" again is noise.
|
||||
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
|
||||
|
||||
// OPEN — where else this session can go. A tab surface IS a tab already,
|
||||
// so it only offers the window hop (and its own Close, below).
|
||||
const openItems: ItemSpec[] = [
|
||||
...(surface === 'row' && !alreadyTabbed
|
||||
? [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'browser',
|
||||
label: r.openInNewTab,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
// Stack into the MAIN zone as a tab (center dock; the strip
|
||||
// sticky-shows on gain) — the door to the tab bar.
|
||||
openSessionTile(sessionId, 'center')
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'link-external',
|
||||
label: r.newWindow,
|
||||
@@ -128,28 +183,14 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(sessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
: [])
|
||||
]
|
||||
|
||||
// IDENTITY — name/mark/reference the session.
|
||||
const identityItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: r.rename,
|
||||
@@ -157,8 +198,96 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
}),
|
||||
spec({
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// WORK — derive/extract from the session.
|
||||
const workItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||
|
||||
const tabCloseItems: ItemSpec[] =
|
||||
surface === 'tab'
|
||||
? [
|
||||
...(onClose
|
||||
? [
|
||||
spec({
|
||||
disabled: false,
|
||||
icon: 'close',
|
||||
label: t.common.close,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(tabPaneId
|
||||
? [
|
||||
spec({
|
||||
disabled: !closeTargets?.others,
|
||||
icon: 'close-all',
|
||||
label: t.zones.closeOthers,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeOtherTreeTabs(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.right,
|
||||
icon: 'arrow-right',
|
||||
label: t.zones.closeToRight,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeTreeTabsToRight(tabPaneId)
|
||||
}
|
||||
}),
|
||||
spec({
|
||||
disabled: !closeTargets?.all,
|
||||
icon: 'clear-all',
|
||||
label: t.zones.closeAll,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
closeAllTreeTabs(tabPaneId)
|
||||
}
|
||||
})
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
|
||||
// DANGER — put it away / destroy it (delete stays last, destructive-red).
|
||||
const dangerItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: r.archive,
|
||||
@@ -166,7 +295,7 @@ function useSessionActions({
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
@@ -187,11 +316,13 @@ function useSessionActions({
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
const renderItems = (kit: MenuKit) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
{openItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{openItems.length > 0 && <kit.Separator />}
|
||||
{identityItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
iconClassName="size-3.5 text-current"
|
||||
@@ -200,7 +331,30 @@ function useSessionActions({
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
<kit.Separator />
|
||||
{workItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{tabCloseItems.length > 0 && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
</>
|
||||
)}
|
||||
<kit.Separator />
|
||||
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{onHideTabBar && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{renderMenuItem(kit.Item, {
|
||||
disabled: false,
|
||||
icon: 'eye-closed',
|
||||
label: r.hideTabBar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onHideTabBar()
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -225,10 +379,11 @@ interface SessionActionsMenuProps
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{renderItems(DropdownMenuItem)}
|
||||
{renderItems(DROPDOWN_KIT)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renameDialog}
|
||||
@@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
{renderItems(CONTEXT_KIT)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{renameDialog}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { startSessionDrag } from '@/app/chat/session-drag'
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -13,7 +13,9 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
@@ -74,10 +76,26 @@ export function SidebarSessionRow({
|
||||
// Telegram thread continued here still reads as Telegram.
|
||||
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
||||
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
// True when a clarify prompt in this session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
// to collapse them at the leaf is backwards.
|
||||
const dotState: SessionDotState = needsInput
|
||||
? 'needs-input'
|
||||
: isWorking
|
||||
? 'working'
|
||||
: hasBackground
|
||||
? 'background'
|
||||
: isUnread
|
||||
? 'unread'
|
||||
: 'idle'
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
@@ -92,7 +110,7 @@ export function SidebarSessionRow({
|
||||
>
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions>
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
@@ -130,21 +148,19 @@ export function SidebarSessionRow({
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
|
||||
// native drag so the two DnD systems don't fight.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
|
||||
event.preventDefault()
|
||||
|
||||
onPointerDown={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
|
||||
// cluster keeps its own gestures. Everything else on the row —
|
||||
// including the row-body BUTTON, the natural grab surface — is a
|
||||
// session drag source: a POINTER drag on the shared drag session
|
||||
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
|
||||
// instantly). Sub-threshold releases stay ordinary clicks, so
|
||||
// resume / pin / open-in-window are untouched.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
|
||||
return
|
||||
}
|
||||
|
||||
writeSessionDrag(event.dataTransfer, {
|
||||
id: session.id,
|
||||
profile: session.profile || 'default',
|
||||
title
|
||||
})
|
||||
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
|
||||
}}
|
||||
ref={ref}
|
||||
style={style}
|
||||
@@ -153,7 +169,40 @@ export function SidebarSessionRow({
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
// Middle-click = open in a new tab (browser muscle memory). Swallow
|
||||
// the mousedown so Chromium doesn't enter autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
openSessionTile(session.id, 'center')
|
||||
}
|
||||
}}
|
||||
onClick={event => {
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
|
||||
// ⇧⌘-click → pop into its own window (needs standalone windows).
|
||||
if (mod && event.shiftKey && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘/⌃-click → open in a new tab (stack into main).
|
||||
if (mod) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
openSessionTile(session.id, 'center')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ⇧-click → pin.
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -163,21 +212,9 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
onMouseDown={event => event.button === 1 && event.preventDefault()}
|
||||
>
|
||||
{reorderable ? (
|
||||
<SidebarRowGrab
|
||||
@@ -189,13 +226,12 @@ export function SidebarSessionRow({
|
||||
<SessionRowLeadDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
dotState={dotState}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
@@ -216,15 +252,18 @@ export function SidebarSessionRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** The session's display state for the sidebar lead dot. The call site
|
||||
* resolves this from the four underlying signals (needs-input, working,
|
||||
* background, unread) so the dot component itself is a pure lookup. */
|
||||
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
dotState = 'idle',
|
||||
className
|
||||
}: {
|
||||
branchStem?: string
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
@@ -234,49 +273,77 @@ function SessionRowLeadDot({
|
||||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
<SidebarRowDot dotState={dotState} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
className
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
// A pure lookup table: each state maps to its className, aria-label, and
|
||||
// title. No priority resolution here — the call site already picked one.
|
||||
// Label/title are resolved from sidebar.row translations, keyed by name.
|
||||
type DotVariant = {
|
||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||
className: string
|
||||
role?: 'status'
|
||||
title?: (r: Translations['sidebar']['row']) => string
|
||||
}
|
||||
|
||||
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along".
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
// pulse of an active turn.
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label={r.needsInput}
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title={r.waitingForAnswer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
|
||||
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
|
||||
className
|
||||
)}
|
||||
role={isWorking ? 'status' : undefined}
|
||||
aria-label={variant.ariaLabel?.(r)}
|
||||
className={cn(variant.className, className)}
|
||||
role={variant.role}
|
||||
title={variant.title?.(r)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { SplitDir } from '@/store/session-states'
|
||||
|
||||
/** The leaf + submenu components for one menu flavour, so the split submenu
|
||||
* renders in either the `…` dropdown or a right-click context menu. */
|
||||
export interface SplitMenuKit {
|
||||
Item: typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
|
||||
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
|
||||
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
|
||||
}
|
||||
|
||||
export const DROPDOWN_SPLIT_KIT: SplitMenuKit = {
|
||||
Item: DropdownMenuItem,
|
||||
Sub: DropdownMenuSub,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
SubTrigger: DropdownMenuSubTrigger
|
||||
}
|
||||
|
||||
export const CONTEXT_SPLIT_KIT: SplitMenuKit = {
|
||||
Item: ContextMenuItem,
|
||||
Sub: ContextMenuSub,
|
||||
SubContent: ContextMenuSubContent,
|
||||
SubTrigger: ContextMenuSubTrigger
|
||||
}
|
||||
|
||||
// Ordered so the default (right) sits first, one hop away.
|
||||
const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [
|
||||
{ dir: 'right', icon: 'arrow-right', label: 'Right' },
|
||||
{ dir: 'bottom', icon: 'arrow-down', label: 'Down' },
|
||||
{ dir: 'left', icon: 'arrow-left', label: 'Left' },
|
||||
{ dir: 'top', icon: 'arrow-up', label: 'Up' }
|
||||
]
|
||||
|
||||
interface SplitSubmenuProps {
|
||||
kit: SplitMenuKit
|
||||
label: string
|
||||
onSplit: (dir: SplitDir) => void
|
||||
disabled?: boolean
|
||||
/** Dismiss the owning menu after the row's default (right) split — the
|
||||
* dropdown is controlled and can; a context menu can't, so it's a no-op. */
|
||||
close?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Open in split ▸": clicking the row splits right (the common case), and the
|
||||
* submenu picks any edge. Shared by session rows and page nav rows.
|
||||
*/
|
||||
export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) {
|
||||
const { Item, Sub, SubContent, SubTrigger } = kit
|
||||
|
||||
const split = (dir: SplitDir) => {
|
||||
triggerHaptic('selection')
|
||||
onSplit(dir)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sub>
|
||||
<SubTrigger
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
split('right')
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<Codicon name="split-horizontal" size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</SubTrigger>
|
||||
<SubContent>
|
||||
{SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => (
|
||||
<Item key={dir} onSelect={() => split(dir)}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{dirLabel}</span>
|
||||
</Item>
|
||||
))}
|
||||
</SubContent>
|
||||
</Sub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Command-palette contribution surface — `palette` data contributions become
|
||||
* rows in the ⌘K root list, same schema as every other area. Contributions
|
||||
* with an `action` id render that action's live keybind as their hotkey hint.
|
||||
*/
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
||||
export const PALETTE_AREA = 'palette'
|
||||
|
||||
/** Payload of a `palette` data contribution. */
|
||||
export interface PaletteContribution {
|
||||
id: string
|
||||
label: string
|
||||
/** Keybind action id — its live combo renders as the hotkey hint. */
|
||||
action?: string
|
||||
icon?: IconComponent
|
||||
keywords?: string[]
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/** Contributed palette rows, with stable render keys. */
|
||||
export function usePaletteContributions(): Array<PaletteContribution & { key: string }> {
|
||||
return useContributions(PALETTE_AREA)
|
||||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) }))
|
||||
.filter(item => Boolean(item.label && item.run))
|
||||
}
|
||||
@@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
||||
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { usePaletteContributions } from './contrib'
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
|
||||
|
||||
@@ -117,6 +118,7 @@ interface PalettePage {
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
git_branch?: null | string
|
||||
id: string
|
||||
preview?: string
|
||||
title: string
|
||||
@@ -211,6 +213,7 @@ const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
git_branch: session.git_branch ?? null,
|
||||
id: session.id,
|
||||
preview: session.preview ?? undefined,
|
||||
title: sessionTitle(session)
|
||||
@@ -368,6 +371,8 @@ export function CommandPalette() {
|
||||
[t.settings.fieldLabels]
|
||||
)
|
||||
|
||||
const contributedItems = usePaletteContributions()
|
||||
|
||||
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
const cc = t.commandCenter
|
||||
@@ -559,9 +564,26 @@ export function CommandPalette() {
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
}
|
||||
},
|
||||
// Registry-contributed rows (core features + plugins) — one group,
|
||||
// omitted while nothing contributes.
|
||||
...(contributedItems.length > 0
|
||||
? [
|
||||
{
|
||||
heading: cc.commands,
|
||||
items: contributedItems.map(item => ({
|
||||
action: item.action,
|
||||
icon: item.icon ?? Zap,
|
||||
id: item.key,
|
||||
keywords: item.keywords,
|
||||
label: item.label,
|
||||
run: item.run
|
||||
}))
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}, [go, settingsSectionLabel, t, worktrees])
|
||||
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
@@ -664,7 +686,7 @@ export function CommandPalette() {
|
||||
items: sessions.map(session => ({
|
||||
icon: MessageCircle,
|
||||
id: `session-${session.id}`,
|
||||
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
|
||||
label: session.title,
|
||||
run: go(sessionRoute(session.id))
|
||||
}))
|
||||
@@ -702,7 +724,7 @@ export function CommandPalette() {
|
||||
items: archivedSessions.map(session => ({
|
||||
icon: Archive,
|
||||
id: `archived-${session.id}`,
|
||||
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
|
||||
label: session.title,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createContext, memo, useContext } from 'react'
|
||||
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
|
||||
import { StatusbarControls } from '../shell/statusbar-controls'
|
||||
|
||||
import type { WiringApi } from './types'
|
||||
|
||||
/** The controller publishes its wired surfaces here; every registered pane
|
||||
* / chrome slot reads one back through `WiredPane`. */
|
||||
export const ContribWiringContext = createContext<WiringApi | null>(null)
|
||||
|
||||
/** Render a wired surface inside a registered pane / chrome slot.
|
||||
*
|
||||
* Memoized on `part` (its only prop): a zone re-rendering for reasons that
|
||||
* don't touch the wiring — a drag hint sweeping the tree, a sash resize, an
|
||||
* edit-mode toggle — re-renders the group chrome but NOT this component, so
|
||||
* the (expensive) pane body it reads from context is untouched. When the
|
||||
* wiring's `api` genuinely changes, the context read re-renders it as normal. */
|
||||
export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) {
|
||||
const api = useContext(ContribWiringContext)
|
||||
|
||||
if (!api) {
|
||||
if (part === 'statusbar') {
|
||||
return <StatusbarControls items={[]} leftItems={[]} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{api[part]}</>
|
||||
})
|
||||
@@ -0,0 +1,673 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { computed } from 'nanostores'
|
||||
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
|
||||
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
bindTreeSideVisibility,
|
||||
declareDefaultTree,
|
||||
dismissTreePane,
|
||||
dockPaneBeside,
|
||||
markCollapsePane,
|
||||
mirrorLayoutTree,
|
||||
paneRootSide,
|
||||
registerLayoutResetHandler,
|
||||
registerPaneCloser,
|
||||
registerPaneOpener,
|
||||
resetLayoutTree,
|
||||
revealTreePane,
|
||||
setPaneCollapsed,
|
||||
setTreePaneHidden,
|
||||
watchContributedPanes
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { discoverBundledPlugins } from '@/contrib/plugins'
|
||||
import { Slot } from '@/contrib/react/slot'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { LayoutDashboard } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
setFileBrowserOpen,
|
||||
setSidebarOpen,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
|
||||
import type { SessionDragPayload } from '../chat/composer/inline-refs'
|
||||
import { watchRouteTiles } from '../chat/route-tile'
|
||||
import { startSessionDrag } from '../chat/session-drag'
|
||||
import {
|
||||
SessionTileCloseConfirm,
|
||||
stackSessionTilesIntoMain,
|
||||
watchSessionTiles,
|
||||
WorkspaceTabMenu
|
||||
} from '../chat/session-tile'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
|
||||
import { $workspaceIsPage } from '../routes'
|
||||
|
||||
import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes'
|
||||
import { ContribWiring, WiredPane } from './wiring'
|
||||
|
||||
/**
|
||||
* Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting
|
||||
* the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid
|
||||
* (fixed chrome) but are fully composable: title bar renders `titleBar.left/
|
||||
* right` slots; the status bar consumes `statusBar.left/right` DATA
|
||||
* contributions (payload = StatusbarItem). Core registers its items through
|
||||
* the same calls a plugin would use.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane contributions. `data.placement` = semantic role for grid presets;
|
||||
// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's
|
||||
// `Pane` props declare — the layout tree sizes zones by weight (percentage)
|
||||
// but a zone never shrinks/grows past its active pane's clamp.
|
||||
// Headers are contextual (tree-side): a pane alone in a zone shows no
|
||||
// header/tab by default; stacked panes show chips. Double-click a zone
|
||||
// toggles its header either way.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
|
||||
// the contribution (new title) and a fresh closure would remount the chat.
|
||||
const renderWorkspacePane = () => <WiredPane part="chatRoutes" />
|
||||
// The main tab carries the same session context menu as tile tabs (targets
|
||||
// the loaded primary session; no menu on a fresh draft).
|
||||
const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
|
||||
|
||||
/** The `@session` payload for the workspace tab — the loaded primary session,
|
||||
* or null on a fresh draft / full-page view (nothing to link). */
|
||||
const workspaceDragPayload = (): SessionDragPayload | null => {
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
|
||||
if (!selected || $workspaceIsPage.get()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
|
||||
|
||||
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
|
||||
}
|
||||
|
||||
// The main tab drags like a session tile — drop it on a composer to link the
|
||||
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
|
||||
// move when there's no loaded session to carry.
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
|
||||
const payload = workspaceDragPayload()
|
||||
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
startSessionDrag(payload, event, { double, onTap })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
registry.registerMany([
|
||||
{
|
||||
id: 'sessions',
|
||||
area: 'panes',
|
||||
title: 'sessions',
|
||||
// Collapsible: leaves the grid on narrow viewports (edge overlay instead).
|
||||
// dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) —
|
||||
// its default-ish spot beside main, not a random same-placement stack.
|
||||
data: {
|
||||
placement: 'left',
|
||||
collapsible: true,
|
||||
dock: { pane: 'workspace', pos: 'left' },
|
||||
revealAliases: ['chat-sidebar'],
|
||||
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
||||
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
||||
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
|
||||
},
|
||||
render: () => <WiredPane part="sidebar" />
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
area: 'panes',
|
||||
// Live-retitled to the loaded session by syncWorkspaceTitle below.
|
||||
title: 'New session',
|
||||
data: {
|
||||
placement: 'main',
|
||||
minWidth: '22vw',
|
||||
tabDrag: workspaceTabDrag,
|
||||
tabWrap: wrapWorkspaceTab,
|
||||
uncloseable: true
|
||||
},
|
||||
render: renderWorkspacePane
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
area: 'panes',
|
||||
title: 'terminal',
|
||||
// revealOnPreset: choosing a layout that places the terminal (e.g.
|
||||
// "Terminal deck") turns takeover on so the zone actually shows, instead of
|
||||
// staying collapsed behind the ⌃` toggle. height sizes the fixed track (a
|
||||
// single-pane zone declaring a height is a fixed track — the preset weight
|
||||
// is moot): a short deck, not a third of the window.
|
||||
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
|
||||
render: () => <WiredPane part="terminal" />
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
area: 'panes',
|
||||
title: 'files',
|
||||
// dock: re-adoption target after a stale dismissal (see sessions).
|
||||
data: {
|
||||
placement: 'right',
|
||||
collapsible: true,
|
||||
dock: { pane: 'workspace', pos: 'right' },
|
||||
revealAliases: ['file-browser'],
|
||||
width: FILE_BROWSER_DEFAULT_WIDTH,
|
||||
minWidth: FILE_BROWSER_MIN_WIDTH,
|
||||
maxWidth: FILE_BROWSER_MAX_WIDTH
|
||||
},
|
||||
render: () => <FilesPane />
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
area: 'panes',
|
||||
title: 'preview',
|
||||
// The rail brings its OWN tab strip (per-target tabs with close buttons).
|
||||
// Exists only while something is previewed — visibility is bound to the
|
||||
// preview targets below, like every other self-managed surface. dock:
|
||||
// adoption seed only — dockPaneBeside re-docks it next to files on every
|
||||
// reveal anyway (position-aware).
|
||||
data: {
|
||||
placement: 'right',
|
||||
dock: { pane: 'files', pos: 'left' },
|
||||
width: 'clamp(18rem, 36vw, 32rem)',
|
||||
minWidth: PREVIEW_RAIL_MIN_WIDTH,
|
||||
maxWidth: PREVIEW_RAIL_MAX_WIDTH
|
||||
},
|
||||
render: () => <PreviewRailPane />
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
area: 'panes',
|
||||
title: 'review',
|
||||
// The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below
|
||||
// like the other chrome toggles; its zone collapses while hidden.
|
||||
data: {
|
||||
placement: 'right',
|
||||
collapsible: true,
|
||||
revealAliases: [REVIEW_PANE_ID],
|
||||
width: FILE_BROWSER_DEFAULT_WIDTH,
|
||||
minWidth: FILE_BROWSER_MIN_WIDTH,
|
||||
maxWidth: FILE_BROWSER_MAX_WIDTH
|
||||
},
|
||||
render: () => <ReviewPaneContent />
|
||||
},
|
||||
{
|
||||
// Optional chrome — in NO default layout. Adoption stacks it with the
|
||||
// terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it.
|
||||
id: 'logs',
|
||||
area: 'panes',
|
||||
title: 'logs',
|
||||
// revealOnPreset: the Quad layout places logs, so applying it turns the
|
||||
// logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed.
|
||||
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
|
||||
render: () => <LogsPane />
|
||||
}
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chrome contributions. The title bar and status bar are fixed chrome outside
|
||||
// the grid, composable through these areas. Everything real lives in the real
|
||||
// components (TitlebarControls / useStatusbarItems). Sample PLUGIN
|
||||
// contributions don't live here — they're their own files under `src/plugins/`,
|
||||
// auto-discovered by discoverBundledPlugins() below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registry.registerMany([
|
||||
// Titlebar center stays empty on purpose: session title lives in tabs +
|
||||
// sidebar; place/cwd lives in the sidebar project tree. Center is drag
|
||||
// chrome (plugins can still contribute to titleBar.center if needed).
|
||||
// Layout edit mode registers through the SAME declarative surfaces plugins
|
||||
// use: a rebindable keybind (collision-checked in the panel) + a ⌘K row
|
||||
// whose hotkey hint tracks the live binding.
|
||||
{
|
||||
id: 'layout.editMode',
|
||||
area: KEYBINDS_AREA,
|
||||
data: {
|
||||
id: 'layout.editMode',
|
||||
label: 'Toggle layout edit mode',
|
||||
defaults: ['mod+shift+\\'],
|
||||
run: toggleLayoutEditMode
|
||||
} satisfies KeybindContribution
|
||||
},
|
||||
{
|
||||
id: 'layout.editMode',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'layout.editMode',
|
||||
label: 'Toggle layout edit mode',
|
||||
action: 'layout.editMode',
|
||||
icon: LayoutDashboard,
|
||||
keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'],
|
||||
run: toggleLayoutEditMode
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
// The agent's write -> see loop: rescan <hermes home>/desktop-plugins
|
||||
// without relaunching (same-id reloads dispose the previous incarnation).
|
||||
{
|
||||
id: 'plugins.reload',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'plugins.reload',
|
||||
label: 'Reload desktop plugins',
|
||||
keywords: ['plugins', 'reload', 'refresh', 'desktop'],
|
||||
run: () => void discoverRuntimePlugins()
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
{
|
||||
id: 'layout.reset',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'layout.reset',
|
||||
label: 'Reset layout',
|
||||
icon: LayoutDashboard,
|
||||
keywords: ['layout', 'reset', 'default', 'panes'],
|
||||
run: resetLayoutTree
|
||||
} satisfies PaletteContribution
|
||||
},
|
||||
// The keybind panel's non-titlebar door (the keyboard icon is gone).
|
||||
{
|
||||
id: 'keybinds.panel',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'keybinds.panel',
|
||||
label: 'Keyboard shortcuts',
|
||||
keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'],
|
||||
run: toggleKeybindPanel
|
||||
} satisfies PaletteContribution
|
||||
}
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout presets — CHAT (main) always dominates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The REAL default: sessions left, chat main, and the right sidebars in
|
||||
// column order main | … | review | preview | file-browser (files outermost,
|
||||
// preview DIRECTLY left of the file tree). Each is its OWN zone — main
|
||||
// parity: a file double-click slides the preview open as its own pane beside
|
||||
// the tree, never as a tab stacked into the files sidebar. Preview/review
|
||||
// zones collapse to nothing while their pane is hidden (no target / ⌘G off).
|
||||
// This static spot is just the seed — dockPaneBeside keeps preview adjacent
|
||||
// to files WHEREVER files moves (see the target listeners below).
|
||||
const DEFAULT_TREE = split(
|
||||
'row',
|
||||
[
|
||||
group(['sessions'], { id: 'grp-sessions' }),
|
||||
group(['workspace'], { id: 'grp-main' }),
|
||||
split(
|
||||
'column',
|
||||
[
|
||||
split(
|
||||
'row',
|
||||
[
|
||||
group(['review'], { id: 'grp-review' }),
|
||||
group(['preview'], { id: 'grp-preview' }),
|
||||
group(['files'], { id: 'grp-files' })
|
||||
],
|
||||
[1, 1, 1.2],
|
||||
'spl-rail'
|
||||
),
|
||||
group(['terminal'], { id: 'grp-terminal' })
|
||||
],
|
||||
[1.6, 1],
|
||||
'spl-right'
|
||||
)
|
||||
],
|
||||
[1, 3.4, 1.25],
|
||||
'spl-root'
|
||||
)
|
||||
|
||||
const FOCUS_TREE = split(
|
||||
'row',
|
||||
[group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])],
|
||||
[1, 4.6]
|
||||
)
|
||||
|
||||
const TERMINAL_TREE = split(
|
||||
'column',
|
||||
[
|
||||
split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]),
|
||||
group(['terminal'])
|
||||
],
|
||||
[3, 1]
|
||||
)
|
||||
|
||||
const QUAD_TREE = split(
|
||||
'column',
|
||||
[
|
||||
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
|
||||
split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1])
|
||||
],
|
||||
[3, 1]
|
||||
)
|
||||
|
||||
registry.registerMany([
|
||||
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
|
||||
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
|
||||
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
|
||||
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
|
||||
])
|
||||
|
||||
declareDefaultTree(DEFAULT_TREE)
|
||||
|
||||
// Bundled plugins load AFTER core, so a same-id contribution from a plugin
|
||||
// deliberately overrides the core default (last writer wins). Third-party
|
||||
// runtime plugins will flow through the same discovery seam.
|
||||
discoverBundledPlugins()
|
||||
|
||||
// Plugin panes join the tree by their `placement` hint the moment they
|
||||
// register — incl. runtime plugins arriving seconds after boot.
|
||||
watchContributedPanes()
|
||||
|
||||
// Session + route (page) tiles: persisted splits register panes docked beside
|
||||
// main.
|
||||
watchSessionTiles()
|
||||
watchRouteTiles()
|
||||
|
||||
// The main tab reads as its SESSION (the loaded title, "New session" on a
|
||||
// fresh draft) — a stack of main + tiles is then just a row of session names.
|
||||
// register() replaces same-id in place; the render fn is the shared constant
|
||||
// above, so the pane content never remounts.
|
||||
const syncWorkspaceTitle = () => {
|
||||
const selected = $selectedStoredSessionId.get()
|
||||
const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null
|
||||
|
||||
registry.register({
|
||||
id: 'workspace',
|
||||
area: 'panes',
|
||||
title: stored ? storedSessionTitle(stored) : 'New session',
|
||||
data: {
|
||||
// Pages aren't tab-able: the main zone's bar stands down while one shows.
|
||||
headerVeto: $workspaceIsPage.get(),
|
||||
placement: 'main',
|
||||
minWidth: '22vw',
|
||||
tabDrag: workspaceTabDrag,
|
||||
tabWrap: wrapWorkspaceTab,
|
||||
uncloseable: true
|
||||
},
|
||||
render: renderWorkspacePane
|
||||
})
|
||||
}
|
||||
|
||||
$selectedStoredSessionId.listen(syncWorkspaceTitle)
|
||||
$sessions.listen(syncWorkspaceTitle)
|
||||
$workspaceIsPage.listen(syncWorkspaceTitle)
|
||||
|
||||
// Layout reset collapses every session tile into main as a tab (after the
|
||||
// workspace) instead of re-scattering them — pre-placed before adoption.
|
||||
registerLayoutResetHandler(stackSessionTilesIntoMain)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their
|
||||
// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree
|
||||
// reacts — a hidden pane's zone collapses (content stays mounted), the flip
|
||||
// toggle mirrors the root row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindPaneVisibility(
|
||||
paneId: string,
|
||||
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
||||
close?: () => void,
|
||||
open?: () => void
|
||||
) {
|
||||
setTreePaneHidden(paneId, !$open.get())
|
||||
$open.listen(isOpen => setTreePaneHidden(paneId, !isOpen))
|
||||
|
||||
// The tab menu's Close routes through the owning store (never dismissal),
|
||||
// so the pane's toggle buttons stay truthful.
|
||||
if (close) {
|
||||
registerPaneCloser(paneId, close)
|
||||
}
|
||||
|
||||
// The opener is the mirror: preset application (revealOnPreset) shows the
|
||||
// pane through the same store, so the toggle stays truthful.
|
||||
if (open) {
|
||||
registerPaneOpener(paneId, open)
|
||||
}
|
||||
}
|
||||
|
||||
// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES
|
||||
// the zone to a persistent rail (tab stays) instead of hiding it — the
|
||||
// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail
|
||||
// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it.
|
||||
function bindPaneCollapse(
|
||||
paneId: string,
|
||||
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
|
||||
close: () => void,
|
||||
open: () => void
|
||||
) {
|
||||
markCollapsePane(paneId)
|
||||
setPaneCollapsed(paneId, !$open.get())
|
||||
$open.listen(isOpen => setPaneCollapsed(paneId, !isOpen))
|
||||
registerPaneCloser(paneId, close)
|
||||
registerPaneOpener(paneId, open)
|
||||
}
|
||||
|
||||
// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is
|
||||
// DERIVED from where the sessions zone actually sits (TitlebarControls maps
|
||||
// its left/right buttons through it), so dragging sessions across — or
|
||||
// applying a mirrored preset — remaps the buttons automatically. The flip
|
||||
// action (⌘\ / titlebar) mirrors the tree only when they disagree.
|
||||
const sessionsOnRight = () => {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const order = allPaneIds(tree)
|
||||
const sessions = order.indexOf('sessions')
|
||||
const main = order.indexOf('workspace')
|
||||
|
||||
return sessions >= 0 && main >= 0 ? sessions > main : null
|
||||
}
|
||||
|
||||
$layoutTree.subscribe(() => {
|
||||
const flipped = sessionsOnRight()
|
||||
|
||||
if (flipped !== null && flipped !== $panesFlipped.get()) {
|
||||
$panesFlipped.set(flipped)
|
||||
}
|
||||
})
|
||||
|
||||
$panesFlipped.listen(flipped => {
|
||||
const current = sessionsOnRight()
|
||||
|
||||
if (current !== null && current !== flipped) {
|
||||
mirrorLayoutTree()
|
||||
}
|
||||
})
|
||||
|
||||
// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the
|
||||
// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on
|
||||
// that side hides together, whatever panes have been rearranged there.
|
||||
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
|
||||
bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
|
||||
|
||||
// Workspace-scoped surfaces: the file tree and git diff only mean something
|
||||
// inside a project. A detached chat (no cwd) hides them — their zones
|
||||
// collapse and the chat absorbs the width; picking a project brings them
|
||||
// back. The terminal is NOT workspace-gated: unlike the old shell (where it
|
||||
// rode the rail's row and vanished with it), its zone stands on its own.
|
||||
const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim()))
|
||||
|
||||
bindPaneVisibility('files', $hasWorkspace)
|
||||
// ⌘G — the review sidebar appears/disappears (and comes to the front).
|
||||
bindPaneVisibility(
|
||||
'review',
|
||||
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
|
||||
closeReview
|
||||
)
|
||||
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
|
||||
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
|
||||
bindPaneCollapse(
|
||||
'terminal',
|
||||
$terminalTakeover,
|
||||
() => setTerminalTakeover(false),
|
||||
() => setTerminalTakeover(true)
|
||||
)
|
||||
|
||||
// Preview EXISTS only while something is previewed (old-shell semantics:
|
||||
// closing the last preview tab closes the pane; a new target opens + fronts
|
||||
// it). Same visibility binding as every other self-managed surface, driven
|
||||
// by the live targets instead of a toggle.
|
||||
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
|
||||
Boolean(target || fileTarget)
|
||||
)
|
||||
|
||||
bindPaneVisibility('preview', $previewVisible, closeRightRail)
|
||||
|
||||
// Logs are optional chrome: off by default, toggled from ⌘K, persisted.
|
||||
const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool)
|
||||
|
||||
bindPaneCollapse(
|
||||
'logs',
|
||||
$logsOpen,
|
||||
() => $logsOpen.set(false),
|
||||
() => $logsOpen.set(true)
|
||||
)
|
||||
registry.register({
|
||||
id: 'logs.toggle',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'logs.toggle',
|
||||
label: 'Toggle logs',
|
||||
keywords: ['logs', 'agent log', 'tail', 'debug'],
|
||||
run: () => $logsOpen.set(!$logsOpen.get())
|
||||
} satisfies PaletteContribution
|
||||
})
|
||||
|
||||
// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button
|
||||
// flips back) — but only while the pane actually lives in that root side
|
||||
// column. Dragged next to main, a side collapse can't hide it (the collapse
|
||||
// skips main-bearing children), so Close falls back to dismissal there —
|
||||
// otherwise ⌘W/Close silently no-op.
|
||||
registerPaneCloser('sessions', () =>
|
||||
paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions')
|
||||
)
|
||||
registerPaneCloser('files', () =>
|
||||
paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files')
|
||||
)
|
||||
|
||||
// A preview target lands NEXT TO the file tree — position-aware: wherever
|
||||
// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the
|
||||
// preview zone docks directly beside it. A user who drags the preview pane
|
||||
// somewhere pins it there instead (until a preset/reset). Then reveal: open
|
||||
// the side, unhide, front — a NEW target while already visible still fronts.
|
||||
const revealPreview = () => {
|
||||
dockPaneBeside('preview', 'files')
|
||||
revealTreePane('preview')
|
||||
}
|
||||
|
||||
$previewTarget.listen(target => target && revealPreview())
|
||||
$filePreviewTarget.listen(target => target && revealPreview())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ContribController() {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={{ '--sidebar-width': '100%' } as CSSProperties}
|
||||
>
|
||||
<ContribWiring>
|
||||
<div
|
||||
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
|
||||
style={{ '--titlebar-height': '0px' } as CSSProperties}
|
||||
>
|
||||
{/* Title bar: fixed chrome outside the grid, composable via slots.
|
||||
Layout contract (no contribution can break it):
|
||||
- a full-bar DRAG BASE underneath (pointer-events-none, like
|
||||
AppShell's drag strips) — everywhere without content drags
|
||||
the window;
|
||||
- each slot region is width-fit, no-drag, pointer-events-auto,
|
||||
so every contribution is clickable by construction;
|
||||
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
|
||||
tree-published --workspace-left/right vars (pure CSS, no rect
|
||||
threading), clamped to clear the REAL TitlebarControls
|
||||
clusters (fixed, z-70); center is truly window-centered. */}
|
||||
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
|
||||
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
|
||||
clusters instead of overlapping them — Electron's no-drag
|
||||
carve-out of fixed/transformed elements is unreliable, so a
|
||||
full-bar drag base kills their clicks. In-flow slot content
|
||||
still carves via its own no-drag wrapper (the same pattern as
|
||||
the app's session-title button). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
|
||||
}}
|
||||
>
|
||||
<Slot area="titleBar.left" />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
|
||||
<Slot area="titleBar.center" />
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
right:
|
||||
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
|
||||
}}
|
||||
>
|
||||
<Slot area="titleBar.right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LayoutTreeRoot />
|
||||
|
||||
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
|
||||
<SessionTileCloseConfirm />
|
||||
|
||||
{/* The REAL statusbar (model pill, command center, agents, …) with
|
||||
statusBar.left/right contributions merged in. */}
|
||||
<WiredPane part="statusbar" />
|
||||
</div>
|
||||
</ContribWiring>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Referenced type kept for plugin authors' reference (payload shape of
|
||||
// statusBar.* contributions).
|
||||
export type { StatusbarItem }
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { refreshActiveProfile } from '@/store/profile'
|
||||
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
// Cron sessions are written by a background scheduler tick, messaging turns by
|
||||
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
|
||||
// desktop websocket, so poll the bounded lists while the app is visible.
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
const MESSAGING_POLL_INTERVAL_MS = 10_000
|
||||
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
|
||||
|
||||
interface BackgroundSyncParams {
|
||||
activeIsMessaging: boolean
|
||||
activeSessionId: null | string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string
|
||||
refreshActiveMessagingTranscript: () => Promise<unknown> | unknown
|
||||
refreshCronJobs: () => Promise<unknown> | unknown
|
||||
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
|
||||
refreshHermesConfig: () => Promise<unknown> | unknown
|
||||
refreshMessagingSessions: () => Promise<unknown> | unknown
|
||||
refreshSessions: () => Promise<unknown> | unknown
|
||||
requestGateway: GatewayRequester
|
||||
}
|
||||
|
||||
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
|
||||
* re-focus. Returns nothing — meant to live inside an effect. */
|
||||
function visiblePoll(intervalMs: number, tick: () => void): () => void {
|
||||
const run = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(run, intervalMs)
|
||||
document.addEventListener('visibilitychange', run)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', run)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps app data live while the gateway is open: an on-connect reseed (model /
|
||||
* profile / sessions + relative-cwd resolution), the cron / messaging /
|
||||
* open-transcript visibility polls, and the fresh-draft model/config reseed.
|
||||
* All the "the desktop websocket won't tell us, so poll" logic in one place.
|
||||
*/
|
||||
export function useBackgroundSync({
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
refreshActiveMessagingTranscript,
|
||||
refreshCronJobs,
|
||||
refreshCurrentModel,
|
||||
refreshHermesConfig,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: BackgroundSyncParams): void {
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
void refreshSessions()
|
||||
|
||||
// A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the
|
||||
// file tree header — resolve it to the backend's absolute path once.
|
||||
// Session runtime info still overrides later, and never while a session is
|
||||
// active.
|
||||
const cwd = $currentCwd.get().trim()
|
||||
|
||||
if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) {
|
||||
void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd })
|
||||
.then(info => {
|
||||
if (info.cwd && !$activeSessionId.get()) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
|
||||
|
||||
// Keep the cron-jobs section live without a user action (scheduler ticks in
|
||||
// the background); re-check on tab re-focus too.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
// Keep the messaging-platform session lists live (inbound turns are written
|
||||
// by the gateway, not the desktop websocket).
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
|
||||
}, [gatewayState, refreshMessagingSessions])
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || !activeIsMessaging) {
|
||||
return
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
|
||||
void refreshActiveMessagingTranscript()
|
||||
|
||||
return dispose
|
||||
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
|
||||
|
||||
// A fresh new-session draft (gateway open, no active session) re-pulls the
|
||||
// model + config so the composer pill reflects the profile default.
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||
void refreshCurrentModel()
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import {
|
||||
getRememberedRoute,
|
||||
getRememberedSessionId,
|
||||
setRememberedRoute,
|
||||
setRememberedSessionId
|
||||
} from '@/store/session'
|
||||
import { onSessionsChanged } from '@/store/session-sync'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
|
||||
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes'
|
||||
|
||||
interface DesktopIntegrationsParams {
|
||||
chatOpen: boolean
|
||||
hasPreview: boolean
|
||||
locationPathname: string
|
||||
navigate: (to: string, options?: { replace?: boolean }) => void
|
||||
refreshSessions: () => Promise<unknown> | unknown
|
||||
resumeExhaustedSessionId: null | string
|
||||
routedSessionId: null | string
|
||||
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
|
||||
}
|
||||
|
||||
/**
|
||||
* All the Electron-main / OS / cross-window integrations the shell listens for:
|
||||
* update polling, the ⌘W close shortcut, deep links, native-notification
|
||||
* navigation, preview-shortcut enablement, remembered-session restore, and
|
||||
* cross-window session-list sync. Kept out of the wiring controller so the
|
||||
* "talks to the desktop shell" surface reads as one unit.
|
||||
*/
|
||||
export function useDesktopIntegrations({
|
||||
locationPathname,
|
||||
navigate,
|
||||
refreshSessions,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionId
|
||||
}: DesktopIntegrationsParams): void {
|
||||
// Update polling — populates $desktopVersion/$updateStatus, which feed the
|
||||
// statusbar version pill and the update toasts. Also honors the main
|
||||
// process's "open updates" menu request.
|
||||
useEffect(() => {
|
||||
startUpdatePoller()
|
||||
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
stopUpdatePoller()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The renderer OWNS ⌘W: on macOS the native menu accelerator would else
|
||||
// close the window, so claim it unconditionally — the menu then routes ⌘W
|
||||
// to us (close-preview-requested IPC) and we decide tab-vs-window.
|
||||
useEffect(() => {
|
||||
window.hermesDesktop?.setPreviewShortcutActive?.(true)
|
||||
}, [])
|
||||
|
||||
// Remember the open chat (session id for notifications/resume) AND the last
|
||||
// non-overlay route (a page like /skills, or a session route) so a relaunch
|
||||
// lands where you were. Overlays (settings/command-center/…) aren't stored —
|
||||
// you don't want to boot into a modal.
|
||||
useEffect(() => {
|
||||
if (routedSessionId) {
|
||||
setRememberedSessionId(routedSessionId)
|
||||
}
|
||||
|
||||
if (!isOverlayView(appViewForPath(locationPathname))) {
|
||||
setRememberedRoute(locationPathname)
|
||||
}
|
||||
}, [locationPathname, routedSessionId])
|
||||
|
||||
const restoredRef = useRef(false)
|
||||
|
||||
// Restore once on cold start — only when the renderer booted at the default
|
||||
// route (a hidden-then-shown window keeps its own route). Prefer the full
|
||||
// remembered route (covers pages); fall back to the last session id.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) {
|
||||
restoredRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
restoredRef.current = true
|
||||
const route = getRememberedRoute()
|
||||
|
||||
if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) {
|
||||
navigate(route, { replace: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const last = getRememberedSessionId()
|
||||
|
||||
if (last) {
|
||||
navigate(sessionRoute(last), { replace: true })
|
||||
}
|
||||
}, [locationPathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null)
|
||||
}
|
||||
}, [resumeExhaustedSessionId])
|
||||
|
||||
// Native-notification click -> jump to the session (runtime id translated to
|
||||
// the stored id the chat route is keyed by); action buttons resolve in place.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current)))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate, runtimeIdByStoredSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links -> a reviewable /blueprint command in the composer.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
|
||||
// closeable, fall back to closing the window (so ⌘W still works as the
|
||||
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
|
||||
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// Another window mutated the shared session list -> re-pull the sidebar.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return onSessionsChanged(() => void refreshSessions())
|
||||
}, [refreshSessions])
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { setPetActivity } from '@/store/pet'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import {
|
||||
setPetOverlayOpenAppHandler,
|
||||
setPetOverlayScaleHandler,
|
||||
setPetOverlaySubmitHandler
|
||||
} from '@/store/pet-overlay'
|
||||
import { $attentionSessionIds, $sessions } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
interface PetBridgeParams {
|
||||
requestGateway: GatewayRequester
|
||||
resumeSession: (sessionId: string) => Promise<unknown> | unknown
|
||||
submitText: (text: string) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the popped-out pet overlay back into the app: submit a prompt, resize,
|
||||
* and open the most-recent thread, plus mirroring "a session is awaiting the
|
||||
* user" into the pet's pose. Handlers register ONCE through refs tracking the
|
||||
* latest callbacks — re-registering on identity churn leaves a nulled-handler
|
||||
* window that can drop a submit. Primary window only.
|
||||
*/
|
||||
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
const requestGatewayRef = useRef(requestGateway)
|
||||
requestGatewayRef.current = requestGateway
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Alt+wheel resize from the popped-out pet — persist through this window's
|
||||
// gateway (the overlay has none) so it survives restart.
|
||||
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
|
||||
// Mail icon: $sessions is most-recent-first; the pet is global, so "most
|
||||
// recent" is the right target.
|
||||
setPetOverlayOpenAppHandler(() => {
|
||||
const recent = $sessions.get()[0]
|
||||
|
||||
if (recent?.id) {
|
||||
void resumeSessionRef.current(recent.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
setPetOverlayScaleHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
|
||||
// awaitingInput flag so it shows the `waiting` pose.
|
||||
useEffect(() => {
|
||||
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
|
||||
|
||||
sync()
|
||||
|
||||
return $attentionSessionIds.listen(sync)
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { toChatMessages } from '@/lib/chat-messages'
|
||||
import { publishSessionState, setSessionTileDelegate } from '@/store/session-states'
|
||||
import type { SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
|
||||
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
type SessionStateCache = ReturnType<typeof useSessionStateCache>
|
||||
|
||||
interface SessionTileDelegateParams {
|
||||
archiveSession: (storedSessionId: string) => Promise<unknown>
|
||||
branchStoredSession: (storedSessionId: string) => Promise<unknown>
|
||||
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
|
||||
removeSession: (storedSessionId: string) => Promise<unknown>
|
||||
requestGateway: GatewayRequester
|
||||
runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef']
|
||||
sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef']
|
||||
updateSessionState: SessionStateCache['updateSessionState']
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the session-tile delegate: resume / submit / interrupt / slash for
|
||||
* tiled sessions WITHOUT touching the primary view ($activeSessionId /
|
||||
* $messages stay the main thread's). Resume reuses a live runtime binding when
|
||||
* one exists (incl. the main thread's own session); a cold tile binds +
|
||||
* hydrates the cache, which publishSessionState mirrors to the tile.
|
||||
*/
|
||||
export function useSessionTileDelegate({
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
}: SessionTileDelegateParams): void {
|
||||
useEffect(() => {
|
||||
setSessionTileDelegate({
|
||||
archiveSession: async storedSessionId => {
|
||||
await archiveSession(storedSessionId)
|
||||
},
|
||||
branchSession: async storedSessionId => {
|
||||
await branchStoredSession(storedSessionId)
|
||||
},
|
||||
deleteSession: async storedSessionId => {
|
||||
await removeSession(storedSessionId)
|
||||
},
|
||||
executeSlash: async (rawCommand, sessionId) => {
|
||||
await executeSlashCommand(rawCommand, { sessionId })
|
||||
},
|
||||
interruptSession: async runtimeId => {
|
||||
await requestGateway('session.interrupt', { session_id: runtimeId })
|
||||
},
|
||||
resumeTile: async storedSessionId => {
|
||||
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
|
||||
|
||||
if (existing && cached?.storedSessionId === storedSessionId) {
|
||||
publishSessionState(existing, cached)
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const [prefetch, resumed] = await Promise.all([
|
||||
getSessionMessages(storedSessionId).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
|
||||
])
|
||||
|
||||
const runtimeId = resumed?.session_id
|
||||
|
||||
if (!runtimeId) {
|
||||
throw new Error('resume returned no session id')
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
runtimeId,
|
||||
state => ({
|
||||
...state,
|
||||
busy: Boolean(resumed?.info?.running),
|
||||
messages:
|
||||
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
return runtimeId
|
||||
},
|
||||
submitToSession: async (runtimeId, text) => {
|
||||
await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
},
|
||||
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
|
||||
})
|
||||
}, [
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// The contribution-driven shell package. `controller` registers panes /
|
||||
// layouts / chrome and mounts the app root; `wiring` is the data controller +
|
||||
// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar
|
||||
// group setters. Only the controller is a public entry (the app root renders
|
||||
// it); the rest are internal to this directory.
|
||||
export { ContribController } from './controller'
|
||||
export { ContribWiring, WiredPane } from './wiring'
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Real-data panes + composable bar items for the contrib root:
|
||||
*
|
||||
* - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it.
|
||||
* - `FilesPane` — real file browser; activating a file opens it in preview.
|
||||
* - Core statusbar items with LIVE store-backed labels, registered as DATA
|
||||
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
|
||||
* StatusbarItem) — plugins add theirs through the identical call.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { atom } from 'nanostores'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { ChatPreviewRail } from '@/app/chat/right-rail/preview'
|
||||
import { RightSidebarPane } from '@/app/right-sidebar'
|
||||
import { ReviewPane } from '@/app/right-sidebar/review'
|
||||
import type { GroupSetter } from '@/app/shell/group-setter'
|
||||
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { getLogs } from '@/hermes'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout,
|
||||
// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LogsPane() {
|
||||
const { data, error } = useQuery({
|
||||
queryKey: ['contrib-logs-tail'],
|
||||
queryFn: () => getLogs({ lines: 300 }),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No chrome of its own — the zone header (when the user summons it) is the
|
||||
// pane's only label. Just the tail.
|
||||
return (
|
||||
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
|
||||
{data.lines.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview — the real rail, fed by the files pane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Preview-server restart handler, provided by the wiring (usePreviewRouting).
|
||||
* Atom-bridged: this module can't import contrib-wiring (it imports us). */
|
||||
export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null)
|
||||
|
||||
export function PreviewRailPane() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const fileTarget = useStore($filePreviewTarget)
|
||||
const restartPreviewServer = useStore($restartPreviewServer)
|
||||
|
||||
if (!previewTarget && !fileTarget) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" />
|
||||
<span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// The contrib layout zeroes --titlebar-height (content sits BELOW the
|
||||
// titlebar, so the real components' clearance padding must collapse) —
|
||||
// but the rail SIZES its per-file tab strip with that var. Restore the
|
||||
// real value for this subtree so the tabs always render at full height.
|
||||
<div
|
||||
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Open a file from the tree in the real preview pipeline. */
|
||||
function previewFile(path: string) {
|
||||
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
|
||||
.then(target => {
|
||||
if (target) {
|
||||
setCurrentSessionPreviewTarget(target, 'file-browser', path)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
|
||||
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
|
||||
// and titlebar clearance are per-wrapper concerns.
|
||||
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
|
||||
|
||||
export function FilesPane() {
|
||||
return (
|
||||
<div className={ZONE_CONTENT}>
|
||||
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review — the real git diff pane (⌘G / $reviewOpen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReviewPaneContent() {
|
||||
const cwd = useStore($currentCwd)
|
||||
|
||||
// Keyed by cwd like DesktopController so switching projects rebuilds the
|
||||
// diff state instead of showing the previous repo's files.
|
||||
return (
|
||||
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
|
||||
<ReviewPane key={cwd || 'no-cwd'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statusbar composability: plugins contribute DATA items into
|
||||
// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL
|
||||
// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here —
|
||||
// the real statusbar owns the core items (model pill, terminal toggle, …).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Collect statusbar contributions for one side. A `render()` contribution
|
||||
* becomes a render-item (arbitrary stateful node); otherwise the declarative
|
||||
* `data` payload is the StatusbarItem. */
|
||||
export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] {
|
||||
const items = useContributions(`statusBar.${side}`)
|
||||
|
||||
return items
|
||||
.map(c =>
|
||||
c.render
|
||||
? ({
|
||||
id: c.id,
|
||||
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
|
||||
} satisfies StatusbarItem)
|
||||
: (c.data as StatusbarItem)
|
||||
)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Collect TitlebarTool data contributions for one side of the titlebar. */
|
||||
export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] {
|
||||
const items = useContributions(`titleBar.tools.${side}`)
|
||||
|
||||
return items.map(c => c.data as TitlebarTool).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
|
||||
* ChatPreviewRail, …) into the registry: each call replaces the group's items
|
||||
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
|
||||
* the same pipe plugins use. Setting an empty list clears the group.
|
||||
*/
|
||||
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
|
||||
const disposers = new Map<string, () => void>()
|
||||
|
||||
return (id, items, side = 'right') => {
|
||||
const key = `${side}:${id}`
|
||||
|
||||
disposers.get(key)?.()
|
||||
disposers.set(
|
||||
key,
|
||||
registry.registerMany(
|
||||
items.map((item, i) => ({
|
||||
id: `${id}-${i}`,
|
||||
area: `${prefix}.${side}`,
|
||||
source: 'core',
|
||||
order: 100 + i,
|
||||
data: item as object
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's page-facing setters — the same `GroupSetter` shape pages already
|
||||
* take as props, backed by the registry instead of component state. */
|
||||
export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar')
|
||||
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Wiring surfaces — each pane is its own memoized component. Every surface
|
||||
* reads the reactive state it renders from at the leaf (its own atom
|
||||
* subscriptions) and reaches the controller's callbacks through the stable
|
||||
* `actions` bag, so a state change scoped to one surface (or a bare
|
||||
* wiring-controller tick) never re-renders another. This is what keeps the
|
||||
* layout tree's zones independently rendered — the whole point of the shell.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react'
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { $freshDraftReady, $gatewayState } from '@/store/session'
|
||||
|
||||
import { ChatView } from '../chat'
|
||||
import { ChatSidebar } from '../chat/sidebar'
|
||||
import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome'
|
||||
import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes'
|
||||
import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot'
|
||||
import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
|
||||
import { ModelMenuPanel } from '../shell/model-menu-panel'
|
||||
import { StatusbarControls } from '../shell/statusbar-controls'
|
||||
|
||||
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
|
||||
import type { SidebarActions, WiringActions } from './types'
|
||||
|
||||
// Same lazy-view split as DesktopController — pages load on demand. The
|
||||
// full-page views the workspace route table mounts live here; overlay views
|
||||
// (agents/settings/…) are the controller's and stay in wiring.tsx.
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
||||
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
|
||||
|
||||
export function LegacySessionRedirect() {
|
||||
const { sessionId } = useParams()
|
||||
|
||||
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
|
||||
}
|
||||
|
||||
export const SidebarSurface = memo(function SidebarSurface({
|
||||
actions,
|
||||
currentView
|
||||
}: {
|
||||
actions: SidebarActions
|
||||
currentView: ComponentProps<typeof ChatSidebar>['currentView']
|
||||
}) {
|
||||
return <ChatSidebar currentView={currentView} {...actions} />
|
||||
})
|
||||
|
||||
export const TerminalSurface = memo(function TerminalSurface() {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)">
|
||||
<TerminalPaneChrome />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Owns the statusbar's own data hooks (status snapshot poll, contributed
|
||||
* items) so its 15s refresh — and any statusbar-only churn — re-renders the
|
||||
* bar alone, never the chat/sidebar/terminal. */
|
||||
export const StatusbarSurface = memo(function StatusbarSurface({
|
||||
actions,
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen
|
||||
}: {
|
||||
actions: WiringActions
|
||||
agentsOpen: boolean
|
||||
chatOpen: boolean
|
||||
commandCenterOpen: boolean
|
||||
}) {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway)
|
||||
const extraLeftItems = useStatusbarContributions('left')
|
||||
const extraRightItems = useStatusbarContributions('right')
|
||||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
openAgents: actions.openAgents,
|
||||
openCommandCenterSection: actions.openCommandCenterSection,
|
||||
requestGateway: actions.requestGateway,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter: actions.toggleCommandCenter
|
||||
})
|
||||
|
||||
return <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
})
|
||||
|
||||
/** The workspace pane: the real route table (chat + full-page views + plugin
|
||||
* routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway
|
||||
* instance + voice cap arrive as props so a reconnect/config load re-renders
|
||||
* only this surface. ChatView subscribes to its own session atoms, so
|
||||
* streaming never round-trips through the controller. */
|
||||
export const ChatRoutesSurface = memo(function ChatRoutesSurface({
|
||||
actions,
|
||||
maxVoiceRecordingSeconds
|
||||
}: {
|
||||
actions: WiringActions
|
||||
maxVoiceRecordingSeconds?: number
|
||||
}) {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
useContributions(ROUTES_AREA)
|
||||
const routeContributions = contributedRoutes()
|
||||
|
||||
// Recapture the live gateway instance whenever the connection state flips.
|
||||
// getGateway reads a controller ref, so gatewayState is the intentional
|
||||
// re-eval trigger (not a value the computation itself reads).
|
||||
const gateway = useMemo(
|
||||
() => actions.getGateway(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[actions, gatewayState]
|
||||
)
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
() =>
|
||||
gatewayState === 'open' ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gateway || undefined}
|
||||
onSelectModel={actions.selectModel}
|
||||
requestGateway={actions.requestGateway}
|
||||
/>
|
||||
) : null,
|
||||
[actions, gateway, gatewayState]
|
||||
)
|
||||
|
||||
const chatView = (
|
||||
<ChatView
|
||||
gateway={gateway}
|
||||
maxVoiceRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={actions.onAddContextRef}
|
||||
onAddUrl={actions.onAddUrl}
|
||||
onAttachDroppedItems={actions.onAttachDroppedItems}
|
||||
onAttachImageBlob={actions.onAttachImageBlob}
|
||||
onBranchInNewChat={actions.onBranchInNewChat}
|
||||
onCancel={actions.onCancel}
|
||||
onDeleteSelectedSession={actions.onDeleteSelectedSession}
|
||||
onDismissError={actions.onDismissError}
|
||||
onEdit={actions.onEdit}
|
||||
onPasteClipboardImage={actions.onPasteClipboardImage}
|
||||
onPickFiles={actions.onPickFiles}
|
||||
onPickFolders={actions.onPickFolders}
|
||||
onPickImages={actions.onPickImages}
|
||||
onReload={actions.onReload}
|
||||
onRemoveAttachment={actions.onRemoveAttachment}
|
||||
onRestoreToMessage={actions.onRestoreToMessage}
|
||||
onRetryResume={actions.onRetryResume}
|
||||
onSteer={actions.onSteer}
|
||||
onSubmit={actions.onSubmit}
|
||||
onThreadMessagesChange={actions.onThreadMessagesChange}
|
||||
onToggleSelectedPin={actions.onToggleSelectedPin}
|
||||
onTranscribeAudio={actions.onTranscribeAudio}
|
||||
/>
|
||||
)
|
||||
|
||||
// FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a
|
||||
// page is not a tab-able surface, so the zone's double-click header toggle
|
||||
// stands down while one is showing (see onZoneDoubleClick).
|
||||
const page = (view: ReactNode) => (
|
||||
<div className="contents" data-zone-no-header>
|
||||
<Suspense fallback={null}>{view}</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
|
||||
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
|
||||
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
|
||||
<Route element={null} path="agents" />
|
||||
<Route element={null} path="command-center" />
|
||||
<Route element={null} path="cron" />
|
||||
<Route element={null} path="profiles" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="starmap" />
|
||||
{/* Registry-contributed pages (core features + plugins) render in the
|
||||
workspace pane like any built-in view — behind the same blast wall
|
||||
as every other contribution mount. */}
|
||||
{routeContributions.map(route => (
|
||||
<Route
|
||||
element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)}
|
||||
key={route.key}
|
||||
path={route.path.slice(1)}
|
||||
/>
|
||||
))}
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
|
||||
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import type { ChatView } from '../chat'
|
||||
import type { ChatSidebar } from '../chat/sidebar'
|
||||
import type { CommandCenterSection } from '../command-center'
|
||||
import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
|
||||
import type { ModelMenuPanel } from '../shell/model-menu-panel'
|
||||
|
||||
export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway']
|
||||
|
||||
/** The ChatSidebar handlers the controller owns — forwarded verbatim. */
|
||||
export type SidebarActions = Pick<
|
||||
ComponentProps<typeof ChatSidebar>,
|
||||
| 'onArchiveSession'
|
||||
| 'onBranchSession'
|
||||
| 'onDeleteSession'
|
||||
| 'onLoadMoreMessaging'
|
||||
| 'onLoadMoreProfileSessions'
|
||||
| 'onLoadMoreSessions'
|
||||
| 'onManageCronJob'
|
||||
| 'onNavigate'
|
||||
| 'onNewSessionInWorkspace'
|
||||
| 'onNewSessionSplit'
|
||||
| 'onResumeSession'
|
||||
| 'onTriggerCronJob'
|
||||
>
|
||||
|
||||
/** The ChatView handlers the controller owns — forwarded verbatim. */
|
||||
export type ChatActions = Pick<
|
||||
ComponentProps<typeof ChatView>,
|
||||
| 'onAddContextRef'
|
||||
| 'onAddUrl'
|
||||
| 'onAttachDroppedItems'
|
||||
| 'onAttachImageBlob'
|
||||
| 'onBranchInNewChat'
|
||||
| 'onCancel'
|
||||
| 'onDeleteSelectedSession'
|
||||
| 'onDismissError'
|
||||
| 'onEdit'
|
||||
| 'onPasteClipboardImage'
|
||||
| 'onPickFiles'
|
||||
| 'onPickFolders'
|
||||
| 'onPickImages'
|
||||
| 'onReload'
|
||||
| 'onRemoveAttachment'
|
||||
| 'onRestoreToMessage'
|
||||
| 'onRetryResume'
|
||||
| 'onSteer'
|
||||
| 'onSubmit'
|
||||
| 'onThreadMessagesChange'
|
||||
| 'onToggleSelectedPin'
|
||||
| 'onTranscribeAudio'
|
||||
>
|
||||
|
||||
/**
|
||||
* The complete controller-owned callback surface. One object, one stable
|
||||
* identity for the app's life — its fields are mutated in place each render,
|
||||
* so surfaces bound to it never re-render on identity churn but always invoke
|
||||
* the latest closure.
|
||||
*/
|
||||
export interface WiringActions extends SidebarActions, ChatActions {
|
||||
/** The live gateway instance (held in a controller ref). Surfaces recapture
|
||||
* it by subscribing to `$gatewayState`, so no gateway prop needs threading. */
|
||||
getGateway: () => ComponentProps<typeof ChatView>['gateway']
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
requestGateway: GatewayRequester
|
||||
selectModel: ComponentProps<typeof ModelMenuPanel>['onSelectModel']
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
|
||||
/** The four wired surfaces the controller publishes; `WiredPane` renders one by
|
||||
* key inside a registered pane / chrome slot. */
|
||||
export interface WiringApi {
|
||||
sidebar: ReactNode
|
||||
chatRoutes: ReactNode
|
||||
terminal: ReactNode
|
||||
statusbar: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
/**
|
||||
* Real-featureset wiring for the contrib (layout tree) root — the minimal
|
||||
* subset of DesktopController's hook chain that makes the REAL surfaces work:
|
||||
* gateway boot -> sessions list -> click-to-resume -> live transcript ->
|
||||
* composer send, plus the real terminal.
|
||||
*
|
||||
* The wired nodes (sidebar / chat routes / terminal) are exposed through
|
||||
* context; registered panes render `<WiredPane part="…"/>` to consume them.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
import { getSessionMessages, triggerCronJob } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$messagingSessions,
|
||||
$resumeExhaustedSessionId,
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages
|
||||
} from '@/store/session'
|
||||
import { focusOpenSession } from '@/store/session-states'
|
||||
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerInsert } from '../chat/composer/focus'
|
||||
import { useComposerActions } from '../chat/hooks/use-composer-actions'
|
||||
import { CommandPalette } from '../command-palette'
|
||||
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from '../hooks/use-keybinds'
|
||||
import { ModelPickerOverlay } from '../model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
|
||||
import { FileActionDialogs } from '../right-sidebar/file-actions'
|
||||
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
||||
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
|
||||
import { SessionPickerOverlay } from '../session-picker-overlay'
|
||||
import { SessionSwitcher } from '../session-switcher'
|
||||
import { useContextSuggestions } from '../session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from '../session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from '../session/hooks/use-hermes-config'
|
||||
import { useMessageStream } from '../session/hooks/use-message-stream'
|
||||
import { useModelControls } from '../session/hooks/use-model-controls'
|
||||
import { usePreviewRouting } from '../session/hooks/use-preview-routing'
|
||||
import { usePromptActions } from '../session/hooks/use-prompt-actions'
|
||||
import { useRouteResume } from '../session/hooks/use-route-resume'
|
||||
import { useSessionActions } from '../session/hooks/use-session-actions'
|
||||
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
|
||||
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
|
||||
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
|
||||
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from '../shell/keybind-panel'
|
||||
import { titlebarControlsPosition } from '../shell/titlebar'
|
||||
import { TitlebarControls } from '../shell/titlebar-controls'
|
||||
import { UpdatesOverlay } from '../updates-overlay'
|
||||
|
||||
import { ContribWiringContext } from './context'
|
||||
import { useBackgroundSync } from './hooks/use-background-sync'
|
||||
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
|
||||
import { usePetBridge } from './hooks/use-pet-bridge'
|
||||
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
|
||||
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
|
||||
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
|
||||
import type { WiringActions, WiringApi } from './types'
|
||||
|
||||
// Overlay views the controller mounts over the shell — lazy, load on demand.
|
||||
// The workspace-route full-page views (skills/messaging/artifacts) are the
|
||||
// ChatRoutesSurface's and live in ./surfaces.
|
||||
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
|
||||
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
|
||||
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
|
||||
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
|
||||
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
|
||||
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
|
||||
|
||||
// Surfaces (the four wired panes), the render context + WiredPane, and the
|
||||
// WiringActions/WiringApi contracts all live in sibling modules — this file is
|
||||
// the controller that assembles them.
|
||||
export { WiredPane } from './context'
|
||||
|
||||
export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
|
||||
// Stable identity for the whole callback surface (see WiringActions). Mutated
|
||||
// in place each render so memoized surfaces never re-render on churn.
|
||||
const actionsRef = useRef<WiringActions | null>(null)
|
||||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const messagingSessions = useStore($messagingSessions)
|
||||
const profileScope = useStore($profileScope)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
const routeTokenRef = useRef(routeToken)
|
||||
routeTokenRef.current = routeToken
|
||||
const getRouteToken = useCallback(() => routeTokenRef.current, [])
|
||||
|
||||
// Mirror "the workspace is showing a full page" into its atom — the
|
||||
// workspace pane contribution re-registers headerVeto from it, so the main
|
||||
// zone's tab bar stands down on pages (and returns with the chat).
|
||||
useEffect(() => {
|
||||
syncWorkspaceIsPage(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
const {
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
openStarmap,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
starmapOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
} = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
})
|
||||
|
||||
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
|
||||
|
||||
const {
|
||||
loadMoreMessagingForPlatform,
|
||||
loadMoreSessions,
|
||||
loadMoreSessionsForProfile,
|
||||
refreshCronJobs,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions
|
||||
} = useSessionListActions({ profileScope })
|
||||
|
||||
const updateActiveSessionRuntimeInfo = useCallback(
|
||||
(info: { branch?: string; cwd?: string }) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: info.branch ?? state.branch,
|
||||
cwd: info.cwd ?? state.cwd
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { refreshProjectBranch } = useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
|
||||
activeSessionIdRef,
|
||||
refreshProjectBranch
|
||||
})
|
||||
|
||||
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
|
||||
activeSessionId,
|
||||
queryClient,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
async (
|
||||
attempts = 1,
|
||||
storedSessionId = selectedStoredSessionIdRef.current,
|
||||
runtimeSessionId = activeSessionIdRef.current
|
||||
) => {
|
||||
if (!storedSessionId || !runtimeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
const restored = todosForHydration(latestSessionTodos(messages))
|
||||
|
||||
if (restored) {
|
||||
setSessionTodos(runtimeSessionId, restored)
|
||||
} else {
|
||||
clearSessionTodos(runtimeSessionId)
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Best-effort fallback when live stream payloads are empty.
|
||||
}
|
||||
|
||||
if (index < attempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Refresh the open messaging transcript (inbound platform turns arrive via
|
||||
// the background gateway, not the desktop websocket). Signature-gated so a
|
||||
// no-change poll doesn't churn the thread.
|
||||
const refreshActiveMessagingTranscript = useCallback(async () => {
|
||||
const storedSessionId = selectedStoredSessionIdRef.current
|
||||
const runtimeSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (!stored || !isMessagingSource(stored.source)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, stored.profile)
|
||||
const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}`
|
||||
const sig = sessionMessagesSignature(latest.messages)
|
||||
|
||||
if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) {
|
||||
return
|
||||
}
|
||||
|
||||
messagingTranscriptSignatureRef.current.set(signatureKey, sig)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
|
||||
storedSessionId
|
||||
)
|
||||
} catch {
|
||||
// Non-fatal: next poll or manual refresh can hydrate.
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
|
||||
|
||||
const { handleGatewayEvent } = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// Agent-driven preview routing (agent opens a URL/file -> the preview rail
|
||||
// follows) + the preview server restart handler, layered over the base
|
||||
// gateway event stream exactly like DesktopController.
|
||||
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent: handleGatewayEvent,
|
||||
currentCwd,
|
||||
currentView,
|
||||
requestGateway,
|
||||
routedSessionId,
|
||||
selectedStoredSessionId
|
||||
})
|
||||
|
||||
// Composer @-mention context suggestions (files/dirs under the cwd).
|
||||
useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
gatewayState,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
// Expose the restart handler to the preview pane contribution (module
|
||||
// boundary crossed via atom — contrib-panes can't import this file).
|
||||
useEffect(() => {
|
||||
$restartPreviewServer.set(restartPreviewServer)
|
||||
|
||||
return () => $restartPreviewServer.set(null)
|
||||
}, [restartPreviewServer])
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
createBackendSessionForSend,
|
||||
openNewSessionTile,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
startFreshSessionDraft
|
||||
} = useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
resetViewSync,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// A profile switch/create drops to a fresh new-session draft so the
|
||||
// previously open session doesn't bleed across contexts. Skip initial value.
|
||||
const freshSessionRequest = useStore($freshSessionRequest)
|
||||
const lastFreshRef = useRef(freshSessionRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (freshSessionRequest === lastFreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFreshRef.current = freshSessionRequest
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
// Swapping the live gateway to another profile must re-pull that profile's
|
||||
// global model + active-profile pill (both are nanostores — the blanket
|
||||
// invalidateQueries on swap doesn't touch them).
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const lastGatewayProfileRef = useRef(activeGatewayProfile)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGatewayProfile === lastGatewayProfileRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
// Force: the new profile has its own default, so reseed even if the
|
||||
// composer already shows the previous profile's model.
|
||||
void refreshCurrentModel(true)
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
// New session anchored to a workspace (sidebar "+" on a project/worktree).
|
||||
// Seeds cwd + branch from the clicked workspace; an explicit worktree path
|
||||
// also drills the sidebar into that project so the new lane is visible.
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
// A worktree lane carries its own path; the trunk "+" can be path-less
|
||||
// (the main checkout is implicit), so fall back to the active project's
|
||||
// root instead of no-op'ing on null.
|
||||
const target = path?.trim() || resolveNewSessionCwd()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
const resolved = info.cwd || target
|
||||
|
||||
setCurrentCwd(resolved)
|
||||
setCurrentBranch(info.branch || '')
|
||||
|
||||
if (path?.trim()) {
|
||||
restoreWorktree(resolved)
|
||||
void followActiveSessionCwd(resolved)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Composer "branch off into a new worktree": open a fresh session anchored
|
||||
// to the just-created tree, then prefill the task that kicked it off.
|
||||
const startWorkSessionRequest = useStore($startWorkSessionRequest)
|
||||
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStartWorkTokenRef.current = startWorkSessionRequest.token
|
||||
startSessionInWorkspace(startWorkSessionRequest.path)
|
||||
|
||||
if (startWorkSessionRequest.draft) {
|
||||
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
|
||||
}
|
||||
}, [startSessionInWorkspace, startWorkSessionRequest])
|
||||
|
||||
const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway })
|
||||
|
||||
const branchInNewChat = useCallback(
|
||||
async (messageId?: string) => {
|
||||
const branched = await branchCurrentSession(messageId)
|
||||
|
||||
if (branched) {
|
||||
await refreshSessions().catch(() => undefined)
|
||||
}
|
||||
|
||||
return branched
|
||||
},
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
executeSlashCommand,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
getRouteToken,
|
||||
handleSkinCommand,
|
||||
openMemoryGraph: openStarmap,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// Session-tile delegate (resume/submit/interrupt/slash + the session verbs
|
||||
// the tile TAB menu needs, without touching the primary view).
|
||||
useSessionTileDelegate({
|
||||
archiveSession,
|
||||
branchStoredSession,
|
||||
executeSlashCommand,
|
||||
removeSession,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// The popped-out pet overlay's bridge back into the app.
|
||||
usePetBridge({ requestGateway, resumeSession, submitText })
|
||||
|
||||
// Clear a failed turn's red error banner. Errors are renderer-local (never
|
||||
// persisted): a bare error placeholder is dropped entirely; a partial-output
|
||||
// failure keeps its content and sheds the error. Both the runtime cache AND
|
||||
// the live $messages view must be updated — preserveLocalAssistantErrors
|
||||
// re-grafts any still-errored view message on the next session.info flush.
|
||||
const dismissError = useCallback(
|
||||
(messageId: string) => {
|
||||
const runtimeSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!runtimeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
|
||||
messages.flatMap(message => {
|
||||
if (message.id !== messageId || !message.error) {
|
||||
return [message]
|
||||
}
|
||||
|
||||
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ ...message, error: undefined, pending: false }]
|
||||
})
|
||||
|
||||
// View first: the cache update below triggers a re-sync that reads
|
||||
// $messages as the error-preservation baseline.
|
||||
setMessages(clearErrorIn($messages.get()))
|
||||
|
||||
updateSessionState(runtimeSessionId, state => ({
|
||||
...state,
|
||||
messages: clearErrorIn(state.messages)
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname: location.pathname,
|
||||
resumeSession,
|
||||
resumeFailedSessionId,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
})
|
||||
|
||||
// Plugins hear the stream FIRST (isolated fan-out in contrib/events), then
|
||||
// the app dispatches as before — a plugin listener can't affect app flow.
|
||||
const handleGatewayEventWithPlugins = useCallback(
|
||||
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
|
||||
emitGatewayEvent(event)
|
||||
handleDesktopGatewayEvent(event)
|
||||
},
|
||||
[handleDesktopGatewayEvent]
|
||||
)
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleGatewayEventWithPlugins,
|
||||
onConnectionReady: c => {
|
||||
connectionRef.current = c
|
||||
},
|
||||
onGatewayReady: g => {
|
||||
gatewayRef.current = g
|
||||
},
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
const activeIsMessaging =
|
||||
!!selectedStoredSessionId &&
|
||||
isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source)
|
||||
|
||||
// Keep app data live while the gateway is open (on-connect reseed + the
|
||||
// cron / messaging / transcript visibility polls + fresh-draft reseed).
|
||||
useBackgroundSync({
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
refreshActiveMessagingTranscript,
|
||||
refreshCronJobs,
|
||||
refreshCurrentModel,
|
||||
refreshHermesConfig,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
// Electron-main / OS / cross-window integrations: update polling, ⌘W close,
|
||||
// deep links, native-notification nav, preview-shortcut enablement,
|
||||
// remembered-session restore, and cross-window session-list sync.
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
|
||||
useDesktopIntegrations({
|
||||
chatOpen,
|
||||
hasPreview: Boolean(filePreviewTarget || previewTarget),
|
||||
locationPathname: location.pathname,
|
||||
navigate,
|
||||
refreshSessions,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef
|
||||
})
|
||||
|
||||
// Pin/unpin the selected session (statusbar keybind + chat header) — pinned
|
||||
// on the durable lineage-root id so it survives auto-compression.
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId))
|
||||
const pinId = session ? sessionPinId(session) : sessionId
|
||||
|
||||
if ($pinnedSessionIds.get().includes(pinId)) {
|
||||
unpinSession(pinId)
|
||||
} else {
|
||||
pinSession(pinId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Single global listener for every rebindable hotkey plus the on-screen
|
||||
// keybind editor's capture mode (same as DesktopController).
|
||||
useKeybinds({
|
||||
openNewSessionTab: () => void openNewSessionTile('center'),
|
||||
startFreshSession: startFreshSessionDraft,
|
||||
toggleCommandCenter,
|
||||
toggleSelectedPin
|
||||
})
|
||||
|
||||
// The controller's entire callback surface, gathered into the stable
|
||||
// `actions` bag. `nextActions` is TS-checked against WiringActions each
|
||||
// render; its fields are copied into the ref object so `actions` keeps one
|
||||
// identity for the app's life (memoized surfaces don't re-render on churn)
|
||||
// while every handler still closes over the latest values.
|
||||
const nextActions: WiringActions = {
|
||||
onAddContextRef: composer.addContextRefAttachment,
|
||||
onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url),
|
||||
onArchiveSession: sessionId => void archiveSession(sessionId),
|
||||
onAttachDroppedItems: composer.attachDroppedItems,
|
||||
onAttachImageBlob: composer.attachImageBlob,
|
||||
onBranchInNewChat: messageId => void branchInNewChat(messageId),
|
||||
onBranchSession: sessionId => void branchStoredSession(sessionId),
|
||||
onCancel: cancelRun,
|
||||
onDeleteSelectedSession: () => {
|
||||
const id = $selectedStoredSessionId.get()
|
||||
|
||||
if (id) {
|
||||
void removeSession(id)
|
||||
}
|
||||
},
|
||||
onDeleteSession: sessionId => void removeSession(sessionId),
|
||||
onDismissError: dismissError,
|
||||
onEdit: editMessage,
|
||||
onLoadMoreMessaging: loadMoreMessagingForPlatform,
|
||||
onLoadMoreProfileSessions: loadMoreSessionsForProfile,
|
||||
onLoadMoreSessions: loadMoreSessions,
|
||||
onManageCronJob: jobId => {
|
||||
setCronFocusJobId(jobId)
|
||||
navigate(CRON_ROUTE)
|
||||
},
|
||||
onNavigate: selectSidebarItem,
|
||||
onNewSessionInWorkspace: startSessionInWorkspace,
|
||||
onNewSessionSplit: dir => void openNewSessionTile(dir),
|
||||
onPasteClipboardImage: opts => composer.pasteClipboardImage(opts),
|
||||
onPickFiles: () => void composer.pickContextPaths('file'),
|
||||
onPickFolders: () => void composer.pickContextPaths('folder'),
|
||||
onPickImages: () => void composer.pickImages(),
|
||||
onReload: reloadFromMessage,
|
||||
onRemoveAttachment: id => void composer.removeAttachment(id),
|
||||
onRestoreToMessage: restoreToMessage,
|
||||
// Already on screen (open tile, or the main session)? Jump to its tab;
|
||||
// otherwise load it into main.
|
||||
onResumeSession: sessionId => {
|
||||
if (!focusOpenSession(sessionId)) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
},
|
||||
onRetryResume: sessionId => void resumeSession(sessionId, true),
|
||||
onSteer: steerPrompt,
|
||||
onSubmit: submitText,
|
||||
onThreadMessagesChange: handleThreadMessagesChange,
|
||||
onToggleSelectedPin: toggleSelectedPin,
|
||||
onTranscribeAudio: transcribeVoiceAudio,
|
||||
onTriggerCronJob: jobId => {
|
||||
void triggerCronJob(jobId)
|
||||
.then(() => refreshCronJobs())
|
||||
.catch(() => undefined)
|
||||
},
|
||||
getGateway: () => gatewayRef.current,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
requestGateway,
|
||||
selectModel,
|
||||
toggleCommandCenter
|
||||
}
|
||||
|
||||
if (actionsRef.current) {
|
||||
Object.assign(actionsRef.current, nextActions)
|
||||
} else {
|
||||
actionsRef.current = nextActions
|
||||
}
|
||||
|
||||
const actions = actionsRef.current
|
||||
|
||||
// Each pane node is memoized on ONLY the reactive inputs it truly consumes;
|
||||
// everything else reaches its surface through `actions` (stable) or the
|
||||
// surface's own atom subscriptions. A wiring tick that doesn't touch a
|
||||
// node's keys leaves its element reference intact, so `WiredPane` (memoized)
|
||||
// bails on that pane subtree — panes render independently of one another.
|
||||
const sidebarNode = useMemo(
|
||||
() => <SidebarSurface actions={actions} currentView={currentView} />,
|
||||
[actions, currentView]
|
||||
)
|
||||
|
||||
const terminalNode = useMemo(() => <TerminalSurface />, [])
|
||||
|
||||
const statusbarNode = useMemo(
|
||||
() => (
|
||||
<StatusbarSurface
|
||||
actions={actions}
|
||||
agentsOpen={agentsOpen}
|
||||
chatOpen={chatOpen}
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
/>
|
||||
),
|
||||
[actions, agentsOpen, chatOpen, commandCenterOpen]
|
||||
)
|
||||
|
||||
// The voice cap changes only on config load; the gateway instance + all
|
||||
// chat reactivity are subscribed inside ChatRoutesSurface / ChatView.
|
||||
const chatRoutesNode = useMemo(
|
||||
() => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />,
|
||||
[actions, voiceMaxRecordingSeconds]
|
||||
)
|
||||
|
||||
const api = useMemo<WiringApi>(
|
||||
() => ({
|
||||
chatRoutes: chatRoutesNode,
|
||||
sidebar: sidebarNode,
|
||||
statusbar: statusbarNode,
|
||||
terminal: terminalNode
|
||||
}),
|
||||
[chatRoutesNode, sidebarNode, statusbarNode, terminalNode]
|
||||
)
|
||||
|
||||
// The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds,
|
||||
// settings gear) — fixed chrome positioned via the same CSS vars AppShell
|
||||
// sets, computed here from the live connection. Page-registered tools
|
||||
// (preview's monitor/devtools cluster, …) arrive as registry contributions.
|
||||
const leftTitlebarTools = useTitlebarToolContributions('left')
|
||||
const rightTitlebarTools = useTitlebarToolContributions('right')
|
||||
const connection = useStore($connection)
|
||||
const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen))
|
||||
// Exact vertical centering: titlebarControlsPosition() returns
|
||||
// (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls
|
||||
// also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that
|
||||
// constant so cluster center == bar center — measured, not eyeballed.
|
||||
const controlsTranslateY = 2
|
||||
// Windows/WSLg reserve native min/max/close on the right (AppShell parity:
|
||||
// prefer the live WCO measurement, fall back to the static reservation).
|
||||
const measuredOverlayWidth = useWindowControlsOverlayWidth()
|
||||
const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
|
||||
// against the static system cluster — in the tree layout the titlebar band
|
||||
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
|
||||
const SYSTEM_TOOL_COUNT = 4
|
||||
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
: systemToolsWidth
|
||||
|
||||
return (
|
||||
<ContribWiringContext.Provider value={api}>
|
||||
<div
|
||||
className="contents"
|
||||
style={
|
||||
{
|
||||
'--titlebar-controls-left': `${controlsPos.left}px`,
|
||||
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
'--shell-preview-toolbar-gap': systemToolsWidth
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
|
||||
tools={rightTitlebarTools}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
|
||||
<RemoteDisplayBanner />
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)}
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<SessionPickerOverlay onResume={resumeSession} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
<RemoteFolderPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsView
|
||||
gateway={gatewayRef.current}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CommandCenterView
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{agentsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{starmapOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<StarmapView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* The full hotkey map (⌘/ and the titlebar keyboard button). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Toasts above everything. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
|
||||
<FloatingPet />
|
||||
|
||||
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
|
||||
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
</ContribWiringContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -54,8 +54,8 @@ import {
|
||||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
@@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const scriptOnlyJob = jobIsScriptOnly(editor.job)
|
||||
|
||||
const updated = await updateCronJob(
|
||||
editor.job.id,
|
||||
cronEditorUpdates(values, { scriptOnlyJob })
|
||||
@@ -756,6 +757,7 @@ function CronEditorDialog({
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const validationError = validateCronEditor({
|
||||
prompt,
|
||||
schedule,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
// Cheap signature compare so a poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible rows actually changed.
|
||||
export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.every((session, i) => {
|
||||
const other = b[i]
|
||||
|
||||
return (
|
||||
other != null &&
|
||||
session.id === other.id &&
|
||||
session._lineage_root_id === other._lineage_root_id &&
|
||||
session.title === other.title &&
|
||||
session.source === other.source &&
|
||||
session.profile === other.profile &&
|
||||
session.preview === other.preview &&
|
||||
session.message_count === other.message_count &&
|
||||
session.last_active === other.last_active &&
|
||||
session.ended_at === other.ended_at
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ import {
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { resetTileRuntimeBindings } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
@@ -167,6 +168,9 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
// A respawned backend re-mints (recycles) runtime ids, so any tile's
|
||||
// bound runtime id is now stale — drop them so each tile re-resumes.
|
||||
resetTileRuntimeBindings()
|
||||
// Resync state that may have moved on the backend while we were asleep.
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
|
||||
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
CHAT_SIDEBAR_PANE_ID,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
toggleFileBrowserOpen,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
import { requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import { reopenLastClosedTile } from '@/store/session-states'
|
||||
import {
|
||||
$switcherOpen,
|
||||
closeSwitcher,
|
||||
@@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
@@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps {
|
||||
toggleCommandCenter: () => void
|
||||
/** Drop to a fresh new-session draft. */
|
||||
startFreshSession: () => void
|
||||
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
|
||||
openNewSessionTab: () => void
|
||||
/** Pin/unpin the active session. */
|
||||
toggleSelectedPin: () => void
|
||||
}
|
||||
@@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
|
||||
// single-pane (or unfocused) layout falls through to the profile switch.
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
||||
if (!activateTreeTabSlot(slot)) {
|
||||
switchProfileToSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goToSession = (sessionId: null | string) => {
|
||||
@@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newTab': () => deps.openNewSessionTab(),
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher.
|
||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
||||
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
|
||||
...sessionSlotHandlers,
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
@@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
// through instead of silently doing nothing).
|
||||
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
|
||||
|
||||
'view.toggleSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
|
||||
} else {
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
'view.toggleRightSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
|
||||
} else {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
// Narrow-viewport reveal is handled inside the store toggles now.
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
|
||||
// terminal-on-bottom) would leave it a dead key, so it falls back to the
|
||||
// terminal there. The single "secondary panel" toggle.
|
||||
'view.toggleRightSidebar': () =>
|
||||
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
@@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
|
||||
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
|
||||
// On macOS the menu accelerator owns ⌘W and routes through the same
|
||||
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
|
||||
// the Win/Linux path where ⌘W reaches the renderer directly.
|
||||
'view.closeTab': () => void closeActiveTab(),
|
||||
'view.reopenTab': reopenLastClosedTile,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = handlersRef.current[actionId]
|
||||
// Built-in handlers first (they carry React context); contributed
|
||||
// actions bring their own `run` through the registry.
|
||||
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
|
||||
|
||||
if (!handler) {
|
||||
return
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export { DesktopController as default } from './desktop-controller'
|
||||
// The app root is the contribution-driven shell: panes, titlebar/statusbar
|
||||
// items, keybinds, palette commands, routes, and themes all register through
|
||||
// the contribution registry (src/contrib) — core surfaces use the same calls
|
||||
// plugins do. Everything lives under ./contrib: the wiring (gateway boot,
|
||||
// sessions, streams) + pane surfaces, and the pane/layout registration.
|
||||
export { ContribController as default } from './contrib'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -32,8 +34,10 @@ export function OverlayView({
|
||||
// stop propagation themselves, so opening (e.g.) the model picker inside
|
||||
// Settings still closes the picker first instead of the underlying overlay.
|
||||
useEffect(() => {
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,7 +48,10 @@ export function OverlayView({
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
@@ -64,6 +71,12 @@ export function OverlayView({
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
// Window-level chrome: overlays always clear the real titlebar. The
|
||||
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
|
||||
// below its in-flow title bar), and CSS vars inherit through the DOM —
|
||||
// so a fixed overlay mounted inside a zone would read 0 and bleed to
|
||||
// the edges. Re-pin the real height at the overlay root.
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -218,6 +218,7 @@ function ReviewDirRow({
|
||||
<span className="min-w-0 flex-1 truncate" title={node.name}>
|
||||
{node.name}
|
||||
</span>
|
||||
{!open && <DiffCount added={node.added} className="text-[0.64rem] leading-4" removed={node.removed} />}
|
||||
</div>
|
||||
{open && node.children && (
|
||||
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />
|
||||
|
||||
@@ -84,7 +84,9 @@ export function ReviewPane() {
|
||||
{(loading || isRepo) && (
|
||||
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
|
||||
{/* Pure self-naming label — redundant under a zone tab that already
|
||||
says "review", so the zone header hides it (styles.css). */}
|
||||
<SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { atom } from 'nanostores'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
@@ -16,6 +21,11 @@ export type AppView =
|
||||
| 'chat'
|
||||
| 'command-center'
|
||||
| 'cron'
|
||||
// A contributed (plugin) full page at its own route — NOT chat. Without this
|
||||
// distinction contributed paths fell through appViewForPath's 'chat' default,
|
||||
// so the sidebar kept a session highlighted and the titlebar kept the
|
||||
// session-title dropdown while a plugin page was showing.
|
||||
| 'extension'
|
||||
| 'messaging'
|
||||
| 'profiles'
|
||||
| 'settings'
|
||||
@@ -56,6 +66,52 @@ export const APP_ROUTES = [
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
|
||||
|
||||
// ── Contributed routes — the `routes` registry area ─────────────────────────
|
||||
// A contribution mounts a FULL PAGE in the workspace pane at `data.path`
|
||||
// (`render` on the contribution itself, like every other area). Contributed
|
||||
// paths are reserved exactly like APP_ROUTES so the session-id parser never
|
||||
// mistakes them for a session route. Navigate with `host.navigate(path)`.
|
||||
|
||||
export const ROUTES_AREA = 'routes'
|
||||
|
||||
/** Payload of a `routes` contribution's `data`. */
|
||||
export interface RouteContribution {
|
||||
/** Absolute path, e.g. `/kanban`. One segment; no params. */
|
||||
path: string
|
||||
}
|
||||
|
||||
export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> {
|
||||
return registry
|
||||
.getArea(ROUTES_AREA)
|
||||
.map(c => ({
|
||||
key: `${c.source ?? 'core'}:${c.id}`,
|
||||
path: (c.data as RouteContribution | undefined)?.path ?? '',
|
||||
title: c.title,
|
||||
render: c.render!
|
||||
}))
|
||||
.filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path))
|
||||
}
|
||||
|
||||
function isContributedPath(pathname: string): boolean {
|
||||
return contributedRoutes().some(route => route.path === pathname)
|
||||
}
|
||||
|
||||
// ── Contributed sidebar nav — the `sidebar.nav` registry area ────────────────
|
||||
// A DATA contribution adds a row to the sidebar's top nav (below Artifacts).
|
||||
// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up
|
||||
// while the app is there.
|
||||
|
||||
export const SIDEBAR_NAV_AREA = 'sidebar.nav'
|
||||
|
||||
/** Payload of a `sidebar.nav` data contribution. */
|
||||
export interface SidebarNavContribution {
|
||||
/** Codicon name, e.g. `'project'`. */
|
||||
codicon: string
|
||||
label: string
|
||||
/** Route to navigate to (usually a contributed page's path). */
|
||||
path: string
|
||||
}
|
||||
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
@@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean {
|
||||
}
|
||||
|
||||
export function routeSessionId(pathname: string): string | null {
|
||||
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
|
||||
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView {
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
if (isContributedPath(pathname)) {
|
||||
return 'extension'
|
||||
}
|
||||
|
||||
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
|
||||
}
|
||||
|
||||
/** True while the workspace pane shows a FULL PAGE (skills/messaging/
|
||||
* artifacts/plugin routes) instead of the chat. Published by the wiring
|
||||
* (which owns the router location); the workspace pane contribution mirrors
|
||||
* it as `headerVeto` so the zone tab bar stands down on pages. Overlays
|
||||
* (settings/…) don't count — the chat stays beneath them. */
|
||||
export const $workspaceIsPage = atom(false)
|
||||
|
||||
export function syncWorkspaceIsPage(pathname: string): void {
|
||||
const view = appViewForPath(pathname)
|
||||
const isPage = view !== 'chat' && !isOverlayView(view)
|
||||
|
||||
if (isPage !== $workspaceIsPage.get()) {
|
||||
$workspaceIsPage.set(isPage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||
|
||||
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||
@@ -19,6 +19,7 @@ export function SessionSwitcher() {
|
||||
const index = useStore($switcherIndex)
|
||||
const working = useStore($workingSessionIds)
|
||||
const attention = useStore($attentionSessionIds)
|
||||
const unread = useStore($unreadFinishedSessionIds)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeRef = useRef<HTMLDivElement>(null)
|
||||
@@ -33,6 +34,7 @@ export function SessionSwitcher() {
|
||||
|
||||
const workingIds = new Set(working)
|
||||
const attentionIds = new Set(attention)
|
||||
const unreadIds = new Set(unread)
|
||||
|
||||
const pick = (sessionId: string) => {
|
||||
closeSwitcher()
|
||||
@@ -74,7 +76,7 @@ export function SessionSwitcher() {
|
||||
}}
|
||||
ref={selected ? activeRef : undefined}
|
||||
>
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} />
|
||||
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||
{i < 9 && (
|
||||
<span
|
||||
@@ -95,12 +97,18 @@ export function SessionSwitcher() {
|
||||
)
|
||||
}
|
||||
|
||||
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
|
||||
function SwitcherDot({ attention, working, unread }: { attention: boolean; working: boolean; unread: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'size-1 shrink-0 rounded-full',
|
||||
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
|
||||
attention
|
||||
? 'bg-amber-400'
|
||||
: working
|
||||
? 'animate-pulse bg-(--ui-accent)'
|
||||
: unread
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--ui-text-quaternary)/50'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { followActiveSessionCwd } from '@/store/projects'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
$currentCwd,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
@@ -405,7 +406,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
// Per-session twin FIRST (the statusbar reads it for focused tiles);
|
||||
// the primary-only global mirrors the ACTIVE session — ungated it
|
||||
// let a background tile's turn overwrite the primary's count.
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'session.title') {
|
||||
// Live auto-title push (titler runs async, after the turn's refresh).
|
||||
@@ -413,9 +424,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
|
||||
if (storedId && nextTitle) {
|
||||
setSessions(prev =>
|
||||
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
|
||||
)
|
||||
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {
|
||||
|
||||
@@ -1453,6 +1453,7 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
|
||||
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
let releaseResume: () => void = () => {}
|
||||
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }
|
||||
|
||||
@@ -5,8 +5,9 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { type ChatMessage, textPart } from '@/lib/chat-messages'
|
||||
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { normalize } from '@/lib/text'
|
||||
@@ -43,23 +44,28 @@ import type {
|
||||
SessionSteerResponse
|
||||
} from '../../../types'
|
||||
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
applyReloadOptimistic,
|
||||
applyRewindOptimistic,
|
||||
finalizeInterruptedMessages,
|
||||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
} from './rewind'
|
||||
import { useSlashCommand } from './slash'
|
||||
import { useSubmitPrompt } from './submit'
|
||||
import {
|
||||
appendText,
|
||||
blobToDataUrl,
|
||||
delay,
|
||||
friendlyRemoteAttachError,
|
||||
type GatewayRequest,
|
||||
inlineErrorMessage,
|
||||
isSessionBusyError,
|
||||
isSessionNotFoundError,
|
||||
readFileDataUrlForAttach,
|
||||
readImageForRemoteAttach,
|
||||
type SubmitTextOptions,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal,
|
||||
withSessionBusyRetry
|
||||
type SubmitTextOptions
|
||||
} from './utils'
|
||||
|
||||
interface HandoffResult {
|
||||
@@ -402,6 +408,13 @@ export function usePromptActions({
|
||||
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
|
||||
}
|
||||
|
||||
const markCompleted = (): HandoffResult => {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000
|
||||
let lastState = 'pending'
|
||||
|
||||
@@ -424,10 +437,7 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
return markCompleted()
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
@@ -441,10 +451,7 @@ export function usePromptActions({
|
||||
}).catch(() => null)
|
||||
|
||||
if (cleanup?.state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
return markCompleted()
|
||||
}
|
||||
|
||||
return { error: copy.handoff.timedOut, ok: false }
|
||||
@@ -471,7 +478,7 @@ export function usePromptActions({
|
||||
|
||||
const submitText = useCallback(
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const visibleText = sanitizeComposerInput(rawText).trim()
|
||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
||||
@@ -511,21 +518,16 @@ export function usePromptActions({
|
||||
setAwaitingResponse(false)
|
||||
setTurnStartedAt(null)
|
||||
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
|
||||
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
|
||||
|
||||
if (!sessionId) {
|
||||
releaseBusy()
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
setMessages(finalizeInterruptedMessages($messages.get()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
const messages = finalizeInterruptedMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -596,7 +598,7 @@ export function usePromptActions({
|
||||
// window) so the caller can fall back to queueing the words for the next turn.
|
||||
const steerPrompt = useCallback(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = rawText.trim()
|
||||
const text = sanitizeComposerInput(rawText).trim()
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!text || !sessionId) {
|
||||
@@ -630,66 +632,19 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
|
||||
const plan = planReload($messages.get(), parentId)
|
||||
|
||||
const userIndex =
|
||||
parentIndex >= 0
|
||||
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
|
||||
: -1
|
||||
|
||||
if (userIndex < 0) {
|
||||
if (!plan) {
|
||||
return
|
||||
}
|
||||
|
||||
const absoluteUserIndex = parentIndex - userIndex
|
||||
const userMessage = messages[absoluteUserIndex]
|
||||
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
|
||||
|
||||
if (!userText) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetAssistant =
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
|
||||
|
||||
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
|
||||
|
||||
clearNotifications()
|
||||
updateSessionState(activeSessionId, state => {
|
||||
const nextUserIndex = state.messages.findIndex(
|
||||
(message, index) => index > absoluteUserIndex && message.role === 'user'
|
||||
)
|
||||
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: branchGroupId,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [
|
||||
...state.messages.slice(0, absoluteUserIndex + 1),
|
||||
...state.messages
|
||||
.slice(absoluteUserIndex + 1, end)
|
||||
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
|
||||
]
|
||||
}
|
||||
})
|
||||
updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan))
|
||||
|
||||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: activeSessionId,
|
||||
text: userText,
|
||||
truncate_before_user_ordinal: truncateBeforeUserOrdinal
|
||||
},
|
||||
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -714,41 +669,8 @@ export function usePromptActions({
|
||||
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
|
||||
// response interrupts + retries through the shared busy gate.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort. The submit path still gates on the gateway state.
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () =>
|
||||
requestGateway(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (interruptFirst) {
|
||||
await interrupt()
|
||||
}
|
||||
|
||||
try {
|
||||
await submit()
|
||||
} catch (err) {
|
||||
if (!isSessionBusyError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
await interrupt()
|
||||
await withSessionBusyRetry(submit)
|
||||
}
|
||||
},
|
||||
(sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
|
||||
runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst),
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
@@ -761,30 +683,7 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||
|
||||
const fallbackIndex =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? -1
|
||||
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||
|
||||
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
throw new Error('Could not find the message to restore.')
|
||||
}
|
||||
|
||||
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const truncateBeforeUserOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
const plan = planRestore(messages, messageId, target)
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
@@ -797,18 +696,10 @@ export function usePromptActions({
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: state.messages.slice(0, sourceIndex + 1)
|
||||
}))
|
||||
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
|
||||
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
|
||||
} catch (err) {
|
||||
// The rewind never landed (e.g. the gateway stayed busy past the retry
|
||||
// deadline). Roll the optimistic truncation back to the full original
|
||||
@@ -832,34 +723,17 @@ export function usePromptActions({
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === sourceId)
|
||||
const source = messages[sourceIndex]
|
||||
const plan = sessionId ? planEdit(messages, edited) : null
|
||||
|
||||
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
|
||||
if (!sessionId || !plan) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
|
||||
// first, so a live turn is wound down before the resubmit.
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// by ordinal would 422. Submit as a plain resend instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
// Editing rewinds the conversation to this prompt — same as restore — so
|
||||
// drop the abandoned timeline's todos/background rows (and kill the live
|
||||
// processes) before the re-run repopulates them.
|
||||
// new text (submitRewindPrompt interrupts a live turn first). Same as
|
||||
// restore, so drop the abandoned timeline's todos/background rows before
|
||||
// the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
@@ -868,33 +742,20 @@ export function usePromptActions({
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
|
||||
|
||||
const isStaleTargetError = (err: unknown) =>
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(
|
||||
sessionId,
|
||||
text,
|
||||
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
|
||||
busyRef.current || $busy.get()
|
||||
)
|
||||
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
if (!isFailedTurn && isStaleTargetError(err)) {
|
||||
if (!plan.isFailedTurn && isStaleTargetError(err)) {
|
||||
try {
|
||||
// Already interrupted on the first attempt — submit as a plain resend.
|
||||
await submitRewindPrompt(sessionId, text, undefined, false)
|
||||
await submitRewindPrompt(sessionId, plan.text, undefined, false)
|
||||
|
||||
return
|
||||
} catch (retryErr) {
|
||||
@@ -917,34 +778,13 @@ export function usePromptActions({
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => {
|
||||
const visibleIds = new Set(nextMessages.map(m => m.id))
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
let changed = false
|
||||
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
}
|
||||
|
||||
const hidden = !visibleIds.has(message.id)
|
||||
|
||||
if (message.hidden === hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...message, hidden }
|
||||
})
|
||||
|
||||
return changed ? { ...state, messages } : state
|
||||
})
|
||||
updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
@@ -952,6 +792,9 @@ export function usePromptActions({
|
||||
return {
|
||||
cancelRun,
|
||||
editMessage,
|
||||
// Session tiles route their slash input here (targets THEIR session via
|
||||
// options.sessionId; app-level effects — branch, handoff — act on main).
|
||||
executeSlashCommand,
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Shared rewind/interrupt core for the prompt verbs — the ONE implementation
|
||||
* of the submit primitive + the pure message math behind cancel / reload /
|
||||
* restore / edit / branch-visibility. Both the primary chat (`index.ts`) and
|
||||
* session tiles (`session-tile-actions.ts`) build on these so the two surfaces
|
||||
* can't silently diverge (the tile's "sends only once" busy-ref bug was exactly
|
||||
* that class of drift). The functions here are PURE — planners compute from a
|
||||
* `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next
|
||||
* — so each caller keeps its own state-write + error-handling wiring.
|
||||
*/
|
||||
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
|
||||
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
|
||||
|
||||
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
|
||||
/**
|
||||
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
|
||||
* (drops that user turn + everything after). Idle rewinds submit directly
|
||||
* (interrupting an idle agent can leave a stale interrupt flag that cancels the
|
||||
* fresh turn); live/stuck turns interrupt first, and a raced "session busy"
|
||||
* response interrupts + retries through the shared busy gate.
|
||||
*/
|
||||
export async function runRewindSubmit(
|
||||
requestGateway: RequestGateway,
|
||||
sessionId: string,
|
||||
text: string,
|
||||
truncateOrdinal: number | undefined,
|
||||
interruptFirst: boolean
|
||||
): Promise<void> {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort. The submit path still gates on the gateway state.
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () =>
|
||||
requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (interruptFirst) {
|
||||
await interrupt()
|
||||
}
|
||||
|
||||
try {
|
||||
await submit()
|
||||
} catch (err) {
|
||||
if (!isSessionBusyError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
await interrupt()
|
||||
await withSessionBusyRetry(submit)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */
|
||||
export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] {
|
||||
return messages
|
||||
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
|
||||
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload (regenerate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReloadPlan {
|
||||
branchGroupId: string
|
||||
text: string
|
||||
truncateOrdinal: number
|
||||
userIndex: number
|
||||
}
|
||||
|
||||
/** The user turn to re-run for a reload from `parentId` (or the last turn). */
|
||||
export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan {
|
||||
const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1
|
||||
|
||||
const userBack =
|
||||
parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1
|
||||
|
||||
if (userBack < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userIndex = parentIndex - userBack
|
||||
const userMessage = messages[userIndex]
|
||||
const text = userMessage ? chatMessageText(userMessage).trim() : ''
|
||||
|
||||
if (!userMessage || !text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetAssistant =
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(userIndex + 1).find(m => m.role === 'assistant')
|
||||
|
||||
return {
|
||||
branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage),
|
||||
text,
|
||||
truncateOrdinal: visibleUserOrdinal(messages, userIndex),
|
||||
userIndex
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic reload state: keep the user turn, hide the branch's assistants. */
|
||||
export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState {
|
||||
const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user')
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: true,
|
||||
busy: true,
|
||||
interrupted: false,
|
||||
messages: [
|
||||
...state.messages.slice(0, plan.userIndex + 1),
|
||||
...state.messages
|
||||
.slice(plan.userIndex + 1, end)
|
||||
.map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m))
|
||||
],
|
||||
pendingBranchGroup: plan.branchGroupId,
|
||||
sawAssistantPayload: false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore (rewind checkpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RestoreTarget {
|
||||
text?: string
|
||||
userOrdinal?: null | number
|
||||
}
|
||||
|
||||
export interface RestorePlan {
|
||||
sourceIndex: number
|
||||
text: string
|
||||
truncateOrdinal: number
|
||||
}
|
||||
|
||||
/** Resolve the user turn to rewind to; throws with a user-facing reason. */
|
||||
export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan {
|
||||
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||
|
||||
const fallbackIndex =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? -1
|
||||
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||
|
||||
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
throw new Error('Could not find the message to restore.')
|
||||
}
|
||||
|
||||
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const truncateOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
|
||||
return { sourceIndex, text, truncateOrdinal }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit (revert + resubmit with new text)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EditPlan {
|
||||
editedMessage: ChatMessage
|
||||
isFailedTurn: boolean
|
||||
sourceIndex: number
|
||||
text: string
|
||||
truncateOrdinal: number | undefined
|
||||
}
|
||||
|
||||
/** Resolve the edited user turn, or null when nothing changed / invalid. */
|
||||
export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null {
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sourceId || !text || edited.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceIndex = messages.findIndex(m => m.id === sourceId)
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Failed turn: the optimistic user msg never reached the gateway, so a
|
||||
// truncate-by-ordinal would 422 — resubmit plainly instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
|
||||
return {
|
||||
editedMessage: { ...source, parts: [textPart(text)] },
|
||||
isFailedTurn,
|
||||
sourceIndex,
|
||||
text,
|
||||
truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic rewind-to state for restore/edit: drop everything after the
|
||||
* source turn (edit swaps in the edited message; restore keeps the original). */
|
||||
export function applyRewindOptimistic(
|
||||
state: ClientSessionState,
|
||||
sourceIndex: number,
|
||||
editedMessage?: ChatMessage
|
||||
): ClientSessionState {
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: true,
|
||||
busy: true,
|
||||
interrupted: false,
|
||||
messages: editedMessage
|
||||
? [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
: state.messages.slice(0, sourceIndex + 1),
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch visibility (assistant-ui hides non-active branches)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Sync each assistant branch message's `hidden` to what the thread renders. */
|
||||
export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState {
|
||||
const visibleIds = new Set(next.map(m => m.id))
|
||||
let changed = false
|
||||
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
}
|
||||
|
||||
const hidden = !visibleIds.has(message.id)
|
||||
|
||||
if (message.hidden === hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...message, hidden }
|
||||
})
|
||||
|
||||
return changed ? { ...state, messages } : state
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import type { Translations } from '@/i18n'
|
||||
import { type ChatMessage, textPart } from '@/lib/chat-messages'
|
||||
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$composerAttachments,
|
||||
@@ -48,6 +49,26 @@ interface SubmitPromptDeps {
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
/** Composer-scope seams: the main chat runs on the module-level globals
|
||||
* (defaults); a session tile injects its own so a tile submit never writes
|
||||
* the primary view's $busy/$messages or clears the main attachment chips. */
|
||||
scope?: {
|
||||
clearAttachments: () => void
|
||||
readAttachments: () => ComposerAttachment[]
|
||||
setAwaitingResponse: (awaiting: boolean) => void
|
||||
setBusy: (busy: boolean) => void
|
||||
setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
// Stable identity — a fresh default object per render would churn the
|
||||
// useCallback below on every render.
|
||||
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
|
||||
clearAttachments: clearComposerAttachments,
|
||||
readAttachments: () => $composerAttachments.get(),
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
}
|
||||
|
||||
/** The prompt submit pipeline, extracted from usePromptActions. */
|
||||
@@ -62,12 +83,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
updateSessionState,
|
||||
scope = MAIN_SUBMIT_SCOPE
|
||||
} = deps
|
||||
|
||||
return useCallback(
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const visibleText = sanitizeComposerInput(rawText).trim()
|
||||
const usingComposerAttachments = !options?.attachments
|
||||
|
||||
// Drop undefined/null holes a session switch or draft restore can leave in
|
||||
@@ -75,7 +97,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
|
||||
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
|
||||
// and break the chat surface.
|
||||
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
|
||||
const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment =>
|
||||
Boolean(a)
|
||||
)
|
||||
|
||||
@@ -158,8 +180,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
const releaseBusy = () => {
|
||||
releaseSubmitLock()
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
scope.setBusy(false)
|
||||
scope.setAwaitingResponse(false)
|
||||
}
|
||||
|
||||
// Idempotent optimistic insert — re-running with the resolved sessionId
|
||||
@@ -198,7 +220,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
|
||||
const dropOptimistic = (sid: null | string) => {
|
||||
if (!sid) {
|
||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -224,8 +246,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
}
|
||||
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
scope.setBusy(true)
|
||||
scope.setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
|
||||
let sessionId: null | string = activeSessionId
|
||||
@@ -233,7 +255,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
} else {
|
||||
setMessages(current => [...current, buildUserMessage()])
|
||||
scope.setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId && startingStoredSessionId) {
|
||||
@@ -382,7 +404,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
}
|
||||
|
||||
if (usingComposerAttachments) {
|
||||
clearComposerAttachments()
|
||||
scope.clearAttachments()
|
||||
}
|
||||
|
||||
// Submit landed — the turn now runs (busy stays true), but the submit
|
||||
@@ -439,6 +461,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
createBackendSessionForSend,
|
||||
getRouteToken,
|
||||
requestGateway,
|
||||
scope,
|
||||
selectedStoredSessionIdRef,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
|
||||
@@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
|
||||
storedSessionId: 'stored-1',
|
||||
streamId: null,
|
||||
turnStartedAt: null,
|
||||
usage: null,
|
||||
yolo: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { revealTreePane } from '@/components/pane-shell/tree/store'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
@@ -88,6 +90,54 @@ interface SessionActionsOptions {
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
// Stored ids created in THIS renderer run. A brand-new session lives only in the
|
||||
// gateway's in-memory map until its first turn persists a state.db row — so if a
|
||||
// respawning/flapping backend drops it, both resume RPC and the REST transcript
|
||||
// 404 even though the user just made it. We must NOT treat that as "gone" (which
|
||||
// yanks them to a fresh draft — the "new sessions clear themselves" bug); the
|
||||
// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id
|
||||
// (NOT in this set) still legitimately drops to a draft.
|
||||
const createdThisRun = new Set<string>()
|
||||
|
||||
// Reflect a stored row's persisted token counts into the live usage atom
|
||||
// (total is derived, so callers can't drift it out of sync with input/output).
|
||||
function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) {
|
||||
const input = stored.input_tokens || 0
|
||||
const output = stored.output_tokens || 0
|
||||
|
||||
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
|
||||
}
|
||||
|
||||
// `session.create` params from the current profile + sticky-UI model/effort/fast,
|
||||
// ensuring the gateway is on that profile first. Shared by the primary send path
|
||||
// and the "open in split" tile path; `cwd` is the one thing that differs (the
|
||||
// live composer cwd for a send, the resolved new-session cwd for a fresh tile).
|
||||
//
|
||||
// Resolving null profile to the active gateway's is load-bearing: in global-remote
|
||||
// mode one backend serves every profile, so an omitted profile silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to default" bug.
|
||||
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
|
||||
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
|
||||
// never the profile default (that lives in Settings → Model).
|
||||
async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> {
|
||||
const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(profile)
|
||||
|
||||
const model = $currentModel.get().trim()
|
||||
const provider = $currentProvider.get().trim()
|
||||
const effort = $currentReasoningEffort.get().trim()
|
||||
|
||||
return {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
...(model ? { model, ...(provider ? { provider } : {}) } : {}),
|
||||
...(effort ? { reasoning_effort: effort } : {}),
|
||||
...($currentFastMode.get() ? { fast: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
interface FreshSessionDraftOptions {
|
||||
replaceRoute?: boolean
|
||||
workspaceTarget?: NewChatWorkspaceTarget
|
||||
@@ -186,19 +236,9 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// A plain new session (top "New Session", /new, keybind) leaves
|
||||
// $newChatProfile null to mean "use the live context"; the per-profile
|
||||
// "+" sets it explicitly. Resolve null to the active gateway profile so
|
||||
// session.create always carries it: in global-remote mode one backend
|
||||
// serves every profile, so an omitted profile param silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to
|
||||
// default" bug. This is a no-op for single-profile/local-pooled users:
|
||||
// a backend resolves its own launch profile to None (_profile_home).
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
// An explicit one-shot workspace target (null → detached, string → that
|
||||
// folder) wins; otherwise fall through to the live cwd, then the
|
||||
// project-aware default (resolveNewSessionCwd).
|
||||
// folder) wins; otherwise the live cwd, then the project-aware default
|
||||
// (resolveNewSessionCwd — a project's new session keeps its repo cwd).
|
||||
const workspaceTarget = $newChatWorkspaceTarget.get()
|
||||
|
||||
const cwd =
|
||||
@@ -208,26 +248,8 @@ export function useSessionActions({
|
||||
? workspaceTarget.trim()
|
||||
: $currentCwd.get().trim() || resolveNewSessionCwd()
|
||||
|
||||
// The composer's model/effort/fast is sticky UI state ($currentModel,
|
||||
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
|
||||
// with every session.create so the new chat opens on whatever the picker
|
||||
// shows — applied as per-session overrides, never written to the profile
|
||||
// default (that lives in Settings → Model).
|
||||
const uiModel = $currentModel.get().trim()
|
||||
const uiProvider = $currentProvider.get().trim()
|
||||
const uiEffort = $currentReasoningEffort.get().trim()
|
||||
const uiFast = $currentFastMode.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
|
||||
...(uiFast ? { fast: true } : {})
|
||||
})
|
||||
|
||||
const params = await desktopSessionCreateParams(cwd)
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
@@ -246,6 +268,7 @@ export function useSessionActions({
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
createdThisRun.add(stored)
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
@@ -310,6 +333,44 @@ export function useSessionActions({
|
||||
[navigate, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
|
||||
* Used by the New session row's "Open in split" menu (and any future
|
||||
* "new chat beside" affordance). */
|
||||
const openNewSessionTile = useCallback(
|
||||
async (dir: TileDock = 'right') => {
|
||||
try {
|
||||
// Fresh tile → the resolved new-session cwd (project/default), not the
|
||||
// primary composer's live cwd.
|
||||
const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim())
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id
|
||||
|
||||
if (!stored) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
createdThisRun.add(stored)
|
||||
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
|
||||
// selection — this session lives in the tile. Prime it with the create
|
||||
// runtime so the tile skips a redundant resume.
|
||||
upsertOptimisticSession(created, stored, null, null)
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
|
||||
|
||||
openSessionTile(stored, dir)
|
||||
patchSessionTile(stored, { runtimeId: created.session_id })
|
||||
revealTreePane(`session-tile:${stored}`)
|
||||
broadcastSessionsChanged()
|
||||
} catch (error) {
|
||||
notifyError(error, copy.createSessionFailed)
|
||||
}
|
||||
},
|
||||
[copy, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
navigate(SETTINGS_ROUTE)
|
||||
}, [navigate])
|
||||
@@ -375,6 +436,7 @@ export function useSessionActions({
|
||||
if (state.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(runtimeId)
|
||||
dropSessionState(runtimeId)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -423,11 +485,13 @@ export function useSessionActions({
|
||||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
publishSessionState(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
dropSessionState(cachedRuntimeId)
|
||||
} else {
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
@@ -464,6 +528,7 @@ export function useSessionActions({
|
||||
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
dropSessionState(cachedRuntimeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,6 +536,16 @@ export function useSessionActions({
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
|
||||
// warm path can still bail down to here — an empty-transcript drop, or the
|
||||
// cache getting purged during the profile-swap await — so the PREVIOUS
|
||||
// session's transcript would leak into this cold resume ("switching
|
||||
// sessions shows the same messages"). Clear it so the loader/prefetch
|
||||
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
|
||||
if ($messages.get().length > 0) {
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(false)
|
||||
@@ -485,12 +560,7 @@ export function useSessionActions({
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
applyStoredUsage(stored)
|
||||
}
|
||||
|
||||
let resumedRunning = false
|
||||
@@ -642,6 +712,16 @@ export function useSessionActions({
|
||||
// permanently-dead id. (Booting straight into a no-longer-existent
|
||||
// last-session id is the common trigger.)
|
||||
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
|
||||
// A session created THIS run isn't gone — its backend just flapped
|
||||
// before the turn-less session persisted. Keep the empty view and arm
|
||||
// the bounded retry to rebind, rather than yanking to a fresh draft.
|
||||
// Only a stale id from a PRIOR run drops to a draft.
|
||||
if (createdThisRun.has(storedSessionId)) {
|
||||
setResumeFailedSessionId(storedSessionId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
startFreshSessionDraft(true)
|
||||
|
||||
return
|
||||
@@ -876,6 +956,18 @@ export function useSessionActions({
|
||||
if (closingRuntimeId) {
|
||||
clearQueuedPrompts(closingRuntimeId)
|
||||
}
|
||||
|
||||
// A tiled copy of this session must not outlive it: collapse the pane
|
||||
// and evict its mirrored runtime state so nothing submits to (or renders)
|
||||
// a deleted session.
|
||||
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
closeSessionTile(storedSessionId)
|
||||
|
||||
if (tiledRuntimeId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
|
||||
dropSessionState(tiledRuntimeId)
|
||||
}
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
@@ -892,12 +984,7 @@ export function useSessionActions({
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
applyStoredUsage(stored)
|
||||
}
|
||||
|
||||
setMessages(previousMessages)
|
||||
@@ -918,8 +1005,10 @@ export function useSessionActions({
|
||||
copy,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
)
|
||||
@@ -956,6 +1045,16 @@ export function useSessionActions({
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
// An archived session is hidden from the sidebar; its tile must go too.
|
||||
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
closeSessionTile(storedSessionId)
|
||||
|
||||
if (tiledRuntimeId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
|
||||
dropSessionState(tiledRuntimeId)
|
||||
}
|
||||
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
@@ -968,7 +1067,13 @@ export function useSessionActions({
|
||||
notifyError(err, copy.archiveFailed)
|
||||
}
|
||||
},
|
||||
[copy, selectedStoredSessionId, startFreshSessionDraft]
|
||||
[
|
||||
copy,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -977,6 +1082,7 @@ export function useSessionActions({
|
||||
branchStoredSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
openNewSessionTile,
|
||||
openSettings,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
|
||||
import {
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
@@ -20,6 +21,10 @@ import {
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
// Re-exported for the many session-actions/tile call sites that already import
|
||||
// it from here; the canonical definition lives in @/store/session.
|
||||
export { sessionMatchesStoredId }
|
||||
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
@@ -183,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
|
||||
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
}
|
||||
|
||||
export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
|
||||
return (session?.message_count ?? 0) > 0
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
|
||||
import { sameCronSignature } from '@/lib/session-signatures'
|
||||
import {
|
||||
isMessagingSource,
|
||||
LOCAL_SESSION_SOURCE_IDS,
|
||||
@@ -29,8 +30,6 @@ import {
|
||||
setSessionsTotal
|
||||
} from '@/store/session'
|
||||
|
||||
import { sameCronSignature } from '../../desktop-controller-utils'
|
||||
|
||||
// The recents list is local-only: cron rows have their own section, and each
|
||||
// messaging platform (telegram, discord, …) is fetched separately into its own
|
||||
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -263,6 +264,10 @@ export function useSessionStateCache({
|
||||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
// Mirror into the reactive multi-session store — session tiles (and any
|
||||
// other non-primary surface) subscribe per runtime id there instead of
|
||||
// through the single active $messages view.
|
||||
publishSessionState(sessionId, next)
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PluginsSettings } from './plugins-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
@@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'notifications',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
@@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : activeView === 'plugins' ? (
|
||||
<PluginsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
@@ -186,3 +186,139 @@ describe('ModelSettings', () => {
|
||||
expect(await screen.findByText(/still run on/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelSettings MoA preset editor', () => {
|
||||
const moaConfig = () => ({
|
||||
default_preset: 'default',
|
||||
active_preset: '',
|
||||
presets: {
|
||||
default: {
|
||||
reference_models: [
|
||||
{ provider: 'nous', model: 'hermes-4' },
|
||||
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
|
||||
],
|
||||
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
|
||||
reference_temperature: 0,
|
||||
aggregator_temperature: 0,
|
||||
max_tokens: 4096,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
reference_models: [
|
||||
{ provider: 'nous', model: 'hermes-4' },
|
||||
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
|
||||
],
|
||||
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
|
||||
reference_temperature: 0,
|
||||
aggregator_temperature: 0,
|
||||
max_tokens: 4096,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
name: 'Nous',
|
||||
slug: 'nous',
|
||||
models: ['hermes-4', 'hermes-4-mini'],
|
||||
authenticated: true,
|
||||
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
slug: 'openrouter',
|
||||
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
|
||||
authenticated: true
|
||||
}
|
||||
]
|
||||
})
|
||||
getMoaModels.mockResolvedValue(moaConfig())
|
||||
saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
|
||||
})
|
||||
|
||||
async function openReferenceEditor() {
|
||||
await renderModelSettings()
|
||||
expect(await screen.findByText('Reference 1')).toBeTruthy()
|
||||
}
|
||||
|
||||
function slotSelects() {
|
||||
// Combobox order in the MoA section (last 7 on the page): preset select,
|
||||
// then provider+model per reference (2 refs), then aggregator
|
||||
// provider+model. Reference 1's pair is therefore at -6 / -5.
|
||||
const all = screen.getAllByRole('combobox')
|
||||
|
||||
return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
|
||||
}
|
||||
|
||||
it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
await openReferenceEditor()
|
||||
|
||||
fireEvent.click(slotSelects().ref1Provider)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
|
||||
|
||||
// Model was cleared by the provider change → config incomplete → the
|
||||
// debounced autosave must NOT fire, even well past the 600ms window.
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(saveMoaModels).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('saves once the model pick completes the slot', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
await openReferenceEditor()
|
||||
|
||||
fireEvent.click(slotSelects().ref1Provider)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
|
||||
fireEvent.click(slotSelects().ref1Model)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
|
||||
expect(saveMoaModels).toHaveBeenCalledTimes(1)
|
||||
const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
|
||||
expect(sent.presets.default.reference_models[0]).toEqual({
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-opus-4.8'
|
||||
})
|
||||
// The untouched slots ride along unchanged — nothing reverts to defaults.
|
||||
expect(sent.presets.default.reference_models[1]).toEqual({
|
||||
provider: 'openrouter',
|
||||
model: 'deepseek/deepseek-v4-pro'
|
||||
})
|
||||
expect(sent.presets.default.aggregator).toEqual({
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-opus-4.8'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear the model or save when the same provider is re-selected', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
await openReferenceEditor()
|
||||
|
||||
fireEvent.click(slotSelects().ref1Provider)
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
|
||||
// Radix treats re-picking the current value as a no-op (no
|
||||
// onValueChange), so nothing changes: no save, model still shown.
|
||||
expect(saveMoaModels).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('nous · hermes-4')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m
|
||||
export const withActive = (models: readonly string[], active: string): readonly string[] =>
|
||||
active && !models.includes(active) ? [active, ...models] : models
|
||||
|
||||
// A slot is complete when both halves are chosen. Changing a slot's provider
|
||||
// intentionally clears its model (see updateMoaSlot), so every provider change
|
||||
// passes through an incomplete state while the user picks the new model.
|
||||
export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())
|
||||
|
||||
// True when every slot in every preset is fully specified — the only state
|
||||
// that is safe to persist. The backend rejects configs with half-filled slots
|
||||
// (HTTP 422) instead of silently swapping the preset for hardcoded defaults
|
||||
// (#64156), so the autosave must simply wait for the edit to finish rather
|
||||
// than trying to "repair" the payload.
|
||||
export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
|
||||
Object.values(config.presets).every(
|
||||
preset =>
|
||||
preset.reference_models.length > 0 &&
|
||||
preset.reference_models.every(moaSlotComplete) &&
|
||||
moaSlotComplete(preset.aggregator)
|
||||
)
|
||||
|
||||
interface StaleAuxWarningProps {
|
||||
applying: boolean
|
||||
onReset: () => void
|
||||
@@ -317,19 +335,44 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
[]
|
||||
)
|
||||
|
||||
// Guard against stale save responses overwriting newer state.
|
||||
const moaSaveGeneration = useRef(0)
|
||||
|
||||
// Quiet debounced persist for inline MoA edits — mirrors the config page's
|
||||
// autosave so slot/aggregator tweaks save themselves, matching the
|
||||
// preset-level ops (set default / add / delete) that already persist on
|
||||
// click. No `applying` spinner, so selecting stays responsive.
|
||||
//
|
||||
// While any slot is half-filled (provider picked, model pending) the save is
|
||||
// HELD, not sent: the previous complete config stays on disk and the next
|
||||
// edit that completes the slot flushes the whole preset. Every edit bumps
|
||||
// the generation so an in-flight response from an older save can never
|
||||
// repaint over the user's mid-edit state.
|
||||
const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
|
||||
if (moaSaveTimer.current) {
|
||||
window.clearTimeout(moaSaveTimer.current)
|
||||
moaSaveTimer.current = null
|
||||
}
|
||||
|
||||
const generation = moaSaveGeneration.current + 1
|
||||
moaSaveGeneration.current = generation
|
||||
|
||||
if (!moaConfigComplete(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
moaSaveTimer.current = window.setTimeout(() => {
|
||||
void saveMoaModels(next)
|
||||
.then(setMoa)
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)))
|
||||
.then(saved => {
|
||||
if (moaSaveGeneration.current === generation) {
|
||||
setMoa(saved)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (moaSaveGeneration.current === generation) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
}, 600)
|
||||
}, [])
|
||||
|
||||
@@ -359,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
|
||||
const next = { ...slot, ...patch }
|
||||
|
||||
if (patch.provider) {
|
||||
// Picking a new provider invalidates the model choice (models are
|
||||
// per-provider). A same-provider update must not wipe the model — Radix
|
||||
// filters same-value changes, but programmatic callers may not.
|
||||
if (patch.provider && patch.provider !== slot.provider) {
|
||||
next.model = ''
|
||||
}
|
||||
|
||||
@@ -368,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
|
||||
const saveMoa = useCallback(async (next: MoaConfigResponse) => {
|
||||
const epoch = profileEpoch.current
|
||||
|
||||
// Explicit preset ops (set default / add / delete) supersede any pending
|
||||
// debounced slot autosave — cancel it and invalidate in-flight responses
|
||||
// so the two writers can't race each other's state.
|
||||
if (moaSaveTimer.current) {
|
||||
window.clearTimeout(moaSaveTimer.current)
|
||||
moaSaveTimer.current = null
|
||||
}
|
||||
|
||||
moaSaveGeneration.current += 1
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
@@ -991,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{moaSlotProviderOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
|
||||
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
|
||||
|
||||
return (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{provider?.name || slug}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
@@ -1037,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{slot.provider} · {slot.model}
|
||||
{slot.provider} · {slot.model || m.model}
|
||||
</span>
|
||||
}
|
||||
key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}
|
||||
key={`${selectedMoaPreset}-${index}`}
|
||||
title={`Reference ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
@@ -1070,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{moaSlotProviderOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
|
||||
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
|
||||
|
||||
return (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{provider?.name || slug}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { getStatus } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Package } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
|
||||
|
||||
function reveal(file: string) {
|
||||
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function revealPluginsDir() {
|
||||
try {
|
||||
const { hermes_home } = await getStatus()
|
||||
// openDir (not reveal): the door often doesn't exist on first use, and
|
||||
// showItemInFolder on a missing path silently no-ops (esp. Windows).
|
||||
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
|
||||
|
||||
if (result && !result.ok) {
|
||||
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not resolve the plugins folder')
|
||||
}
|
||||
}
|
||||
|
||||
function PluginRow({ record }: { record: PluginRecord }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{record.file && (
|
||||
<Tip label={p.reveal}>
|
||||
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
|
||||
<Codicon name="folder-opened" size="0.85rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Switch
|
||||
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
|
||||
checked={record.status !== 'disabled'}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
void setPluginEnabled(record.id, on)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
record.status === 'error' ? (
|
||||
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
|
||||
) : (
|
||||
record.file ?? record.id
|
||||
)
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{record.name}
|
||||
<Pill>{p.kinds[record.kind]}</Pill>
|
||||
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
const records = useStore($pluginRecords)
|
||||
|
||||
const rows = Object.values(records).sort(
|
||||
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
|
||||
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
|
||||
<Codicon name="folder-opened" size="0.8rem" />
|
||||
{p.openFolder}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
void discoverRuntimePlugins()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8rem" />
|
||||
{p.rescan}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title={p.empty} />
|
||||
) : (
|
||||
<div className="divide-y divide-(--ui-stroke-tertiary)">
|
||||
{rows.map(record => (
|
||||
<PluginRow key={record.id} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type SettingsView =
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
setSidebarOpen
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from './keybind-panel'
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
// Fixed-position overlays that must share <main>'s stacking context so pane
|
||||
// resize handles (z-20) paint above them. The persistent terminal lives here:
|
||||
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
|
||||
// would cover every pane's drag handle.
|
||||
mainOverlays?: ReactNode
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
// Rails that sit at the window's left edge in the flipped layout but never
|
||||
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
|
||||
// lights (and zero the titlebar inset) even below the collapse breakpoint.
|
||||
previewPaneOpen?: boolean
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
terminalPaneOpen?: boolean
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
|
||||
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
|
||||
function subscribeWindowSize(cb: () => void) {
|
||||
window.addEventListener('resize', cb)
|
||||
window.addEventListener('fullscreenchange', cb)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', cb)
|
||||
window.removeEventListener('fullscreenchange', cb)
|
||||
}
|
||||
}
|
||||
|
||||
const viewportIsFullscreen = () =>
|
||||
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
mainOverlays,
|
||||
onOpenSettings,
|
||||
overlays,
|
||||
previewPaneOpen = false,
|
||||
statusbarItems,
|
||||
terminalPaneOpen = false,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
// Every secondary window (new-session scratch, subagent watch, cmd-click
|
||||
// pop-out) is a compact side panel — none of them carry the full titlebar
|
||||
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||
const hideTitlebarControls = isSecondaryWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
|
||||
// macOS, where window controls sit on the left and are reported via
|
||||
// windowButtonPosition instead). The right tool cluster has to clear them.
|
||||
// Prefer the EXACT width measured from the live Window Controls Overlay
|
||||
// (precise + self-correcting across DPI/host themes); fall back to the static
|
||||
// reservation the main process sends when the WCO API isn't available.
|
||||
const measuredOverlayWidth = useWindowControlsOverlayWidth()
|
||||
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
|
||||
// When the native window controls overlay our titlebar band — Windows and
|
||||
// WSLg both paint Electron's Window Controls Overlay and report
|
||||
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
|
||||
// normally lives IN that band) would render at y=0 under the fixed titlebar
|
||||
// tool cluster and collide with it. Drop the right rail one titlebar-height so
|
||||
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
|
||||
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
|
||||
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
|
||||
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead. Both force-collapse to a
|
||||
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
|
||||
// is uncovered there regardless of their stored open state. A standalone
|
||||
// session window renders no sidebar at all, so its edge is always uncovered.
|
||||
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||
// The terminal + preview rails never force-collapse, so when they're the
|
||||
// leftmost open pane (flipped layout) they cover the edge even when narrow.
|
||||
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
|
||||
|
||||
const leftEdgePaneOpen =
|
||||
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
// The static system cluster (haptics, profiles, settings, right-sidebar) is
|
||||
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
|
||||
// in a separate cluster anchored further left.
|
||||
//
|
||||
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
|
||||
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
|
||||
// between the pane-tool cluster and the system cluster so they don't sit
|
||||
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
|
||||
// to keep the formula generic for any pane-tool count.
|
||||
const SYSTEM_TOOL_COUNT = 4
|
||||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
|
||||
const fileBrowserWidth =
|
||||
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
|
||||
|
||||
// Where the pane-tool cluster's right edge sits, measured from the inner
|
||||
// titlebar padding (--titlebar-tools-right). Two anchors:
|
||||
// - file-browser closed → flush against static cluster's left edge
|
||||
// - file-browser open → flush against the file-browser pane's left edge
|
||||
// (= preview pane's right edge)
|
||||
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
|
||||
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize
|
||||
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
|
||||
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
|
||||
// is enough.
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
||||
: systemToolsWidth
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 flex-col bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
||||
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
||||
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
// Drops the right rail below the titlebar band when the OS/host paints
|
||||
// window controls over it (Windows/WSLg); 0px elsewhere.
|
||||
'--right-rail-top-inset': rightRailTopInset,
|
||||
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
||||
// Sourced from the layout store rather than the PaneShell-emitted
|
||||
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
||||
// and CSS variables resolve at the consumer's scope.
|
||||
'--shell-preview-toolbar-gap': previewToolbarGap
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{!hideTitlebarControls && (
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
)}
|
||||
|
||||
{nativeOverlayWidth > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{children}
|
||||
</PaneShell>
|
||||
|
||||
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
|
||||
after PaneShell so it paints over pane content, but its z stays under
|
||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||
{mainOverlays}
|
||||
|
||||
{/* The compact pop-out drops the statusbar — it's a scratch window, not
|
||||
the full shell. */}
|
||||
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getLogs({ file: 'gui', lines: LOG_TAIL })
|
||||
.then(res => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// async: getLogs THROWS (not rejects) when the desktop bridge is missing
|
||||
// (plain-browser mode) — a sync throw here would take down the root
|
||||
// error boundary before the .catch even attaches.
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await getLogs({ file: 'gui', lines: LOG_TAIL })
|
||||
|
||||
setLines(
|
||||
res.lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !LOG_NOISE_RE.test(line))
|
||||
.slice(-LOG_VISIBLE)
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setLines(
|
||||
res.lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !LOG_NOISE_RE.test(line))
|
||||
.slice(-LOG_VISIBLE)
|
||||
)
|
||||
} catch {
|
||||
// Bridge/gateway unavailable — keep the last tail.
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
const timer = window.setInterval(load, LOG_POLL_MS)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
|
||||
// MessagingView, ChatPreviewRail, …). The live implementation is the
|
||||
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
|
||||
type Side = 'left' | 'right'
|
||||
|
||||
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
|
||||
@@ -22,9 +22,13 @@ import {
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$currentUsage,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
sessionMatchesStoredId
|
||||
} from '@/store/session'
|
||||
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
|
||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||
import { $gatewayRestarting } from '@/store/system-actions'
|
||||
import {
|
||||
@@ -40,6 +44,8 @@ import type { StatusResponse } from '@/types/hermes'
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
|
||||
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
|
||||
|
||||
function workspaceLabel(cwd: string): string {
|
||||
const normalized = cwd.replace(/[\\/]+$/, '')
|
||||
const leaf = normalized.split(/[\\/]/).filter(Boolean).pop()
|
||||
@@ -80,15 +86,15 @@ export function useStatusbarItems({
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar
|
||||
const fileMenu = t.fileMenu
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const primaryActiveSessionId = useStore($activeSessionId)
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const busy = useStore($busy)
|
||||
const primaryBusy = useStore($busy)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const primaryUsage = useStore($currentUsage)
|
||||
const gatewayRestarting = useStore($gatewayRestarting)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const primarySessionStartedAt = useStore($sessionStartedAt)
|
||||
const primaryTurnStartedAt = useStore($turnStartedAt)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
@@ -97,11 +103,42 @@ export function useStatusbarItems({
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
// The FOCUSED session (interacted tile, else the primary — the same
|
||||
// derivation the titlebar title follows): every session-scoped readout
|
||||
// below (context count, timers, busy pulse) tracks it, so clicking into a
|
||||
// tile makes the statusbar describe THAT session.
|
||||
const focusedStoredSessionId = useStore($focusedStoredSessionId)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
const sessions = useStore($sessions)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
|
||||
|
||||
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
|
||||
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
|
||||
|
||||
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
|
||||
// a fresh `{...}` each render would bust the usage-label memos below.
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
|
||||
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
|
||||
|
||||
// A tile's session-start comes from its stored row (the cache only knows
|
||||
// runtime state); seconds → ms.
|
||||
const focusedRow = focusedStoredSessionId
|
||||
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
|
||||
: null
|
||||
|
||||
const sessionStartedAt = primaryFocused
|
||||
? primarySessionStartedAt
|
||||
: focusedRow?.started_at
|
||||
? focusedRow.started_at * 1000
|
||||
: null
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
|
||||
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (close: () => void) => (
|
||||
<GatewayMenuPanel
|
||||
|
||||
@@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
KEYBIND_ACTIONS,
|
||||
allKeybindActions,
|
||||
KEYBIND_CATEGORIES,
|
||||
KEYBIND_PANEL_ACTION,
|
||||
KEYBIND_READONLY,
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
type KeybindReadonly,
|
||||
KEYBINDS_AREA
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
$capture,
|
||||
$keybindPanelOpen,
|
||||
beginCapture,
|
||||
bindingsFor,
|
||||
closeKeybindPanel,
|
||||
conflictsFor,
|
||||
endCapture,
|
||||
@@ -36,6 +39,9 @@ export function KeybindPanel() {
|
||||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
// Subscribe so contributed actions appear/disappear live in the map.
|
||||
useContributions(KEYBINDS_AREA)
|
||||
const actionList = allKeybindActions()
|
||||
|
||||
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
||||
|
||||
@@ -74,7 +80,7 @@ export function KeybindPanel() {
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
|
||||
{KEYBIND_CATEGORIES.map(category => {
|
||||
const actions = KEYBIND_ACTIONS.filter(
|
||||
const actions = actionList.filter(
|
||||
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
||||
)
|
||||
|
||||
@@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
const bindings = useStore($bindings)
|
||||
const capture = useStore($capture)
|
||||
|
||||
const combos = bindings[action.id] ?? []
|
||||
// bindingsFor resolves stored overrides for late-registered (contributed)
|
||||
// actions too — $bindings only carries built-ins, so a raw lookup would show
|
||||
// the default instead of the user's rebinding for a plugin/contrib action.
|
||||
const combos = bindingsFor(action.id, bindings)
|
||||
const capturing = capture === action.id
|
||||
const label = k.actions[action.id] ?? action.id
|
||||
const label = k.actions[action.id] ?? action.label ?? action.id
|
||||
const isDefault = arraysEqual(combos, [...action.defaults])
|
||||
|
||||
const conflict = combos
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface StatusbarMenuItem {
|
||||
|
||||
export interface StatusbarItem {
|
||||
id: string
|
||||
/** Escape hatch: render an arbitrary node into the bar (own state, tooltip,
|
||||
* events). When set, it OWNS the slot — label/variant/onSelect are ignored.
|
||||
* This is how a plugin drops a full stateful React component into the bar. */
|
||||
render?: () => ReactNode
|
||||
label?: ReactNode
|
||||
detail?: ReactNode
|
||||
icon?: ReactNode
|
||||
@@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
|
||||
if (item.render) {
|
||||
return <>{item.render()}</>
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
@@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</>
|
||||
)
|
||||
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) {
|
||||
// The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child,
|
||||
// but DropdownMenu's Root renders no element, so the hover listeners never
|
||||
// land on the button and the tooltip silently never shows. Compose the two
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
@@ -9,10 +11,8 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
toggleFileBrowserOpen,
|
||||
togglePanesFlipped,
|
||||
@@ -32,7 +32,7 @@ export interface TitlebarTool {
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
icon: ReactNode
|
||||
onSelect?: () => void
|
||||
onSelect?: (event?: MouseEvent) => void
|
||||
title?: string
|
||||
to?: string
|
||||
}
|
||||
@@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout button's glyph. Morphs into its composite reset form — the
|
||||
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
|
||||
* to how it was") — ONLY while the pointer is on the button AND ⌘/Ctrl is
|
||||
* held: hover gates via CSS (`group/tool` on the button), the modifier via
|
||||
* the window listener. Pressing the modifier elsewhere changes nothing.
|
||||
*/
|
||||
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
|
||||
<Codicon name="layout" />
|
||||
</span>
|
||||
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
|
||||
<Codicon name="layout" />
|
||||
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
|
||||
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the
|
||||
* layout button morphs into its reset form while the modifier is down). */
|
||||
function useModifierHeld(): boolean {
|
||||
const [held, setHeld] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
|
||||
const clear = () => setHeld(false)
|
||||
|
||||
window.addEventListener('keydown', sync)
|
||||
window.addEventListener('keyup', sync)
|
||||
window.addEventListener('blur', clear)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync)
|
||||
window.removeEventListener('keyup', sync)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const modHeld = useModifierHeld()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
@@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
}
|
||||
}
|
||||
|
||||
// Each titlebar button controls the pane physically on its side, so a flip
|
||||
// swaps which pane each one toggles. Default: sessions left, file browser
|
||||
// right. Flipped: file browser left, sessions right. Sidebar toggles never
|
||||
// carry an active highlight — they're plain show/hide affordances.
|
||||
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
|
||||
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
|
||||
// POSITIONAL toggles: each button shows/hides everything on its physical
|
||||
// side of the main zone (the layout tree collapses the whole side), so they
|
||||
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
|
||||
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
|
||||
// show/hide affordances.
|
||||
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
@@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
className: 'group/tool',
|
||||
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
|
||||
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
|
||||
icon: <LayoutGlyph modHeld={modHeld} />,
|
||||
id: 'layout',
|
||||
label: t.titlebar.layoutEditor,
|
||||
onSelect: event => {
|
||||
if (event?.metaKey || event?.ctrlKey) {
|
||||
triggerHaptic('warning')
|
||||
resetLayoutTree()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('open')
|
||||
toggleLayoutEditMode()
|
||||
},
|
||||
title: t.titlebar.layoutEditorTitle
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
@@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="keyboard" />,
|
||||
id: 'keybinds',
|
||||
label: t.titlebar.openKeybinds,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
toggleKeybindPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
@@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
}
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
|
||||
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
@@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
aria-label={t.shell.appControls}
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visibleSystemToolsBeforeSettings.map(tool => (
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
|
||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||
</div>
|
||||
</>
|
||||
@@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
onClick={event => {
|
||||
if (tool.to) {
|
||||
navigate(tool.to)
|
||||
}
|
||||
|
||||
tool.onSelect?.()
|
||||
tool.onSelect?.(event)
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type Side = 'left' | 'right'
|
||||
type Groups<T> = Record<Side, Record<string, readonly T[]>>
|
||||
|
||||
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
|
||||
|
||||
interface GroupRegistry<T> {
|
||||
flat: { left: T[]; right: T[] }
|
||||
set: GroupSetter<T>
|
||||
}
|
||||
|
||||
export function useGroupRegistry<T>(): GroupRegistry<T> {
|
||||
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
|
||||
|
||||
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
|
||||
setGroups(current => {
|
||||
const next = { ...current, [side]: { ...current[side] } }
|
||||
|
||||
if (items.length === 0) {
|
||||
delete next[side][id]
|
||||
} else {
|
||||
next[side][id] = items
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const flat = useMemo(
|
||||
() => ({
|
||||
left: Object.values(groups.left).flat(),
|
||||
right: Object.values(groups.right).flat()
|
||||
}),
|
||||
[groups]
|
||||
)
|
||||
|
||||
return { flat, set }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user