diff --git a/solstone/apps/chat/_chat_event.html b/solstone/apps/chat/_chat_event.html index 57ebf5627..f242b30b8 100644 --- a/solstone/apps/chat/_chat_event.html +++ b/solstone/apps/chat/_chat_event.html @@ -32,28 +32,28 @@ {% elif ev.kind == "talent_spawned" %} -{% elif ev.kind == "talent_finished" %} + class="chat-talent-card chat-talent-card--spawned" + data-talent-use-id="{{ ev.use_id }}" + data-talent-status="active"> + {{ chat_copy.talent_label_for(status="running", target=ev.name) }} + {% if ev.task %}{{ ev.task }}{% endif %} + + {% elif ev.kind == "talent_finished" %} -{% elif ev.kind == "talent_errored" %} + class="chat-talent-card chat-talent-card--finished" + data-talent-use-id="{{ ev.use_id }}" + data-talent-status="finished"> + {{ chat_copy.talent_label_for(status="finished", target=ev.name) }} + {% if ev.summary %}
{{ ev.summary }}
{% endif %} + + {% elif ev.kind == "talent_errored" %} + class="chat-talent-card chat-talent-card--errored" + data-talent-use-id="{{ ev.use_id }}" + data-talent-status="errored"> + {{ chat_copy.talent_label_for(status="errored", target=ev.name) }} + {% if ev.reason %}
{{ ev.reason }}
{% endif %} + {% elif ev.kind == "reflection_ready" %}
weekly reflection ready diff --git a/solstone/apps/chat/copy.py b/solstone/apps/chat/copy.py new file mode 100644 index 000000000..ca7be2979 --- /dev/null +++ b/solstone/apps/chat/copy.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Owner-facing copy for the chat surface (apps/chat + convey chat-bar).""" + +# fmt: off +# T1.3 — owner-language talent labels (CMO subagent voice pass, 2026-05-26) +TALENT_LABEL_EXEC_RUNNING = "Looking in your journal…" +TALENT_LABEL_EXEC_FINISHED = "Looked in your journal" +TALENT_LABEL_EXEC_ERRORED = "Couldn't finish looking in your journal" +TALENT_LABEL_REFLECTION_RUNNING = "Reflecting…" +TALENT_LABEL_REFLECTION_FINISHED = "Reflected" +TALENT_LABEL_REFLECTION_ERRORED = "Couldn't finish reflecting" + +# T1.4 — queue depth indicators (lowercase "sol" per system-anatomy canon) +CHAT_QUEUE_INDICATOR_SINGULAR = "1 message waiting" +CHAT_QUEUE_INDICATOR_PLURAL_FORMAT = "{count} messages waiting" +CHAT_QUEUE_DEPTH_CAP_MESSAGE = "Give sol a moment to catch up — you have 10 messages waiting." +# fmt: on + +from typing import Literal + +_TALENT_LABELS: dict[tuple[str, str], str] = { + ("exec", "running"): TALENT_LABEL_EXEC_RUNNING, + ("exec", "finished"): TALENT_LABEL_EXEC_FINISHED, + ("exec", "errored"): TALENT_LABEL_EXEC_ERRORED, + ("reflection", "running"): TALENT_LABEL_REFLECTION_RUNNING, + ("reflection", "finished"): TALENT_LABEL_REFLECTION_FINISHED, + ("reflection", "errored"): TALENT_LABEL_REFLECTION_ERRORED, +} + + +def talent_label_for( + target: str, status: Literal["running", "finished", "errored"] +) -> str: + """Return owner-facing label for (target, status). Raises ValueError on unknown.""" + try: + return _TALENT_LABELS[(target, status)] + except KeyError: + raise ValueError( + f"no chat talent label for target={target!r} status={status!r}" + ) diff --git a/solstone/apps/chat/routes.py b/solstone/apps/chat/routes.py index 53b664b17..760699b43 100644 --- a/solstone/apps/chat/routes.py +++ b/solstone/apps/chat/routes.py @@ -9,6 +9,7 @@ from typing import Any from flask import Blueprint, abort, jsonify, redirect, render_template, url_for +from solstone.apps.chat import copy as chat_copy from solstone.convey.chat_stream import read_chat_events from solstone.convey.reasons import INVALID_MONTH from solstone.convey.sol_initiated import copy as sol_voice_copy @@ -67,6 +68,7 @@ def day(day: str) -> str: owner_name=owner_name, agent_name=agent_name, sol_message_origins=sol_message_origins, + chat_copy=chat_copy, sol_voice_copy=sol_voice_copy, ) diff --git a/solstone/apps/chat/tests/test_routes.py b/solstone/apps/chat/tests/test_routes.py index ff618765b..d144c020c 100644 --- a/solstone/apps/chat/tests/test_routes.py +++ b/solstone/apps/chat/tests/test_routes.py @@ -12,7 +12,9 @@ from pathlib import Path from typing import Any import pytest +from markupsafe import escape as markupsafe_escape +from solstone.apps.chat import copy as chat_copy from solstone.convey import create_app from solstone.convey.chat_stream import append_chat_event, read_chat_events from solstone.convey.sol_initiated.copy import ( @@ -157,7 +159,7 @@ def test_chat_day_renders_all_event_kinds(journal_copy, monkeypatch): "talent_spawned", ts=_ms(2099, 1, 2, 9, 2), use_id="use-2", - name="search", + name="exec", task="find updates", started_at=_ms(2099, 1, 2, 9, 2), ) @@ -165,7 +167,7 @@ def test_chat_day_renders_all_event_kinds(journal_copy, monkeypatch): "talent_finished", ts=_ms(2099, 1, 2, 9, 3), use_id="use-2", - name="search", + name="exec", summary="done", ) append_chat_event( @@ -202,6 +204,47 @@ def test_chat_day_renders_all_event_kinds(journal_copy, monkeypatch): assert "I couldn't reach the network" in html +def test_chat_day_renders_owner_language_talent_labels(journal_copy, monkeypatch): + day = "20990102" + _set_today(monkeypatch, "20990103") + env = _make_env(journal_copy, monkeypatch) + + for index, target in enumerate(("exec", "reflection")): + append_chat_event( + "talent_spawned", + ts=_ms(2099, 1, 2, 10 + index, 0), + use_id=f"use-{target}-running", + name=target, + task=f"{target} task", + started_at=_ms(2099, 1, 2, 10 + index, 0), + ) + append_chat_event( + "talent_finished", + ts=_ms(2099, 1, 2, 10 + index, 1), + use_id=f"use-{target}-finished", + name=target, + summary=f"{target} summary", + ) + append_chat_event( + "talent_errored", + ts=_ms(2099, 1, 2, 10 + index, 2), + use_id=f"use-{target}-errored", + name=target, + reason=f"{target} reason", + ) + + response = env.client.get(f"/app/chat/{day}") + html = response.get_data(as_text=True) + + assert response.status_code == 200 + for target in ("exec", "reflection"): + for status in ("running", "finished", "errored"): + label = chat_copy.talent_label_for(target, status) + assert str(markupsafe_escape(label)) in html + for raw in ("started", "finished", "errored"): + assert f"{target} {raw}" not in html + + def test_chat_day_emits_raw_talent_markdown_source_for_bootstrap( journal_copy, monkeypatch ): @@ -212,7 +255,7 @@ def test_chat_day_emits_raw_talent_markdown_source_for_bootstrap( "talent_finished", ts=_ms(2099, 1, 2, 9, 3), use_id="use-md-1", - name="search", + name="exec", summary="**done**", ) append_chat_event( diff --git a/solstone/apps/chat/workspace.html b/solstone/apps/chat/workspace.html index 570ecdcf6..810341fe5 100644 --- a/solstone/apps/chat/workspace.html +++ b/solstone/apps/chat/workspace.html @@ -399,13 +399,13 @@ return fragment; } if (event.kind === 'talent_spawned') { - return buildTalentCard(event.name + ' started', event.task || '', event.use_id, 'active', 'chat-talent-card--spawned'); + return buildTalentCard(window.solChatCopy.talentLabel(event.name, 'running'), event.task || '', event.use_id, 'active', 'chat-talent-card--spawned'); } if (event.kind === 'talent_finished') { - return buildTalentCard(event.name + ' finished', event.summary || '', event.use_id, 'finished', 'chat-talent-card--finished'); + return buildTalentCard(window.solChatCopy.talentLabel(event.name, 'finished'), event.summary || '', event.use_id, 'finished', 'chat-talent-card--finished'); } if (event.kind === 'talent_errored') { - return buildTalentCard(event.name + ' errored', event.reason || '', event.use_id, 'errored', 'chat-talent-card--errored'); + return buildTalentCard(window.solChatCopy.talentLabel(event.name, 'errored'), event.reason || '', event.use_id, 'errored', 'chat-talent-card--errored'); } if (event.kind === 'reflection_ready') { return buildReflectionCard(event.day || '', event.url || ''); diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index b9dedf5d2..6a02f0935 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -12,6 +12,7 @@ import logging import os import pprint import threading +from collections import deque from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -27,6 +28,7 @@ from solstone.convey.chat_stream import ( ) from solstone.convey.reasons import ( AGENT_UNAVAILABLE, + CHAT_QUEUE_FULL, MISSING_REQUIRED_FIELD, TALENT_NOT_FOUND, ) @@ -55,7 +57,7 @@ _state_lock = threading.Lock() _runtime_lock = threading.Lock() _current_chat_use_id: str | None = None _current_chat_state: dict[str, Any] | None = None -_queued_trigger: dict[str, Any] | None = None +_queued_triggers: deque[dict[str, Any]] = deque() _active_talents: dict[str, dict[str, Any]] = {} _reserved_use_ids: dict[str, None] = {} _watchdog_timers: dict[str, threading.Timer] = {} @@ -123,12 +125,17 @@ def post_chat() -> Any: } if source is not None: event_fields["source"] = source - append_chat_event("owner_message", **event_fields) trigger = { "type": "owner_message", "message": message, } + with _state_lock: + if _current_chat_use_id is not None and len(_queued_triggers) >= 10: + return error_response(CHAT_QUEUE_FULL) + + append_chat_event("owner_message", **event_fields) + start_info: dict[str, Any] | None = None with _state_lock: if _current_chat_use_id is None: @@ -137,7 +144,7 @@ def post_chat() -> Any: queued = False response_use_id = logical_use_id else: - response_use_id = _queue_trigger_locked(trigger, location) + response_use_id = _enqueue_trigger_locked(trigger, location) queued = True if start_info is not None: @@ -852,27 +859,33 @@ def _build_spawn_info_locked(logical_use_id: str) -> dict[str, Any]: } -def _queue_trigger_locked(trigger: dict[str, Any], location: dict[str, str]) -> str: - global _queued_trigger - if _queued_trigger is None: - _queued_trigger = { - "use_id": _reserve_use_id_locked(), - "trigger": dict(trigger), - "location": dict(location), - } - return str(_queued_trigger["use_id"]) +def _enqueue_trigger_locked(trigger: dict[str, Any], location: dict[str, str]) -> str: + queued = { + "use_id": _reserve_use_id_locked(), + "trigger": dict(trigger), + "location": dict(location), + } + _queued_triggers.append(queued) + append_chat_event("chat_queue_depth", depth=len(_queued_triggers)) + return str(queued["use_id"]) + + +def _pop_next_trigger_locked() -> dict[str, Any] | None: + if not _queued_triggers: + return None + return _queued_triggers.popleft() def _clear_current_locked() -> dict[str, Any] | None: - global _current_chat_use_id, _current_chat_state, _queued_trigger + global _current_chat_use_id, _current_chat_state _current_chat_use_id = None _current_chat_state = None - if _queued_trigger is None: + queued = _pop_next_trigger_locked() + if queued is None: return None - queued = _queued_trigger - _queued_trigger = None + append_chat_event("chat_queue_depth", depth=len(_queued_triggers)) return _activate_current_locked( str(queued["use_id"]), dict(queued["trigger"]), diff --git a/solstone/convey/chat_stream.py b/solstone/convey/chat_stream.py index 11da7c256..b4467638d 100644 --- a/solstone/convey/chat_stream.py +++ b/solstone/convey/chat_stream.py @@ -44,6 +44,7 @@ _VALID_KINDS = { "talent_finished": ("use_id", "name", "summary"), "talent_errored": ("use_id", "name", "reason"), "reflection_ready": ("day", "url"), + "chat_queue_depth": ("depth",), # chat_error also accepts optional `provider` (provider slug or "") and `detail` # (normalized raw provider error text, "" when absent); neither is validated. "chat_error": ("reason", "use_id"), @@ -210,9 +211,14 @@ def reduce_chat_state(day: str) -> dict[str, Any]: latest_sol_message: dict[str, Any] | None = None active_talents: dict[str, dict[str, Any]] = {} completed_talents: list[dict[str, Any]] = [] + queue_depth = 0 for event in read_chat_events(day): kind = event.get("kind") + if kind == "chat_queue_depth": + queue_depth = int(event["depth"]) + continue + if kind == "sol_message": latest_sol_message = { "ts": event["ts"], @@ -263,6 +269,7 @@ def reduce_chat_state(day: str) -> dict[str, Any]: ), ), "completed_talents": completed_talents, + "queue_depth": queue_depth, } @@ -287,6 +294,9 @@ def _validate_event(kind: str, event: dict[str, Any]) -> None: required = ", ".join(missing) raise ValueError(f"{kind} requires fields: {required}") + if kind == "chat_queue_depth" and not isinstance(event["depth"], int): + raise ValueError("chat_queue_depth depth must be an int") + def _broadcast_chat_event(stored_event: dict[str, Any]) -> None: chat_module = sys.modules.get("solstone.convey.chat") diff --git a/solstone/convey/reasons.py b/solstone/convey/reasons.py index bd62e6f03..ec51ca58b 100644 --- a/solstone/convey/reasons.py +++ b/solstone/convey/reasons.py @@ -178,6 +178,7 @@ AGENT_UNAVAILABLE = Reason( "I couldn't reach the agent service.", 503, ) +CHAT_QUEUE_FULL = Reason("chat_queue_full", "Chat queue full", 429) TALENT_RUN_PENDING = Reason( "talent_run_pending", "I'm still working on that talent run.", diff --git a/solstone/convey/static/app.css b/solstone/convey/static/app.css index 786f74ec5..0bf3e2374 100644 --- a/solstone/convey/static/app.css +++ b/solstone/convey/static/app.css @@ -1722,6 +1722,19 @@ body.menu-all .menu-bar { line-height: 1.4; } +.chat-bar-queue { + flex: 1 0 100%; + min-width: 0; + color: #888; + font-size: 13px; + line-height: 1.4; + white-space: normal; +} + +.chat-bar-queue[hidden] { + display: none; +} + .chat-bar-status-action { flex: 0 0 auto; margin-left: 8px; @@ -1856,11 +1869,11 @@ body.menu-all .menu-bar { background: currentColor; } -.chat-bar-talent[data-status="active"] { +.chat-bar-talent[data-status="running"] { color: var(--facet-color, #b06a1a); } -.chat-bar-talent[data-status="active"] .chat-bar-talent-dot { +.chat-bar-talent[data-status="running"] .chat-bar-talent-dot { animation: chat-bar-talent-pulse 1.2s ease-in-out infinite; } @@ -2619,7 +2632,7 @@ button.close { animation-iteration-count: 1 !important; } - .chat-bar-talent[data-status="active"] .chat-bar-talent-dot { + .chat-bar-talent[data-status="running"] .chat-bar-talent-dot { animation: none; } diff --git a/solstone/convey/static/chat_copy.js b/solstone/convey/static/chat_copy.js new file mode 100644 index 000000000..b80f75cae --- /dev/null +++ b/solstone/convey/static/chat_copy.js @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +(function () { + const TALENT_LABELS = { + "exec": { + "running": "Looking in your journal…", + "finished": "Looked in your journal", + "errored": "Couldn't finish looking in your journal" + }, + "reflection": { + "running": "Reflecting…", + "finished": "Reflected", + "errored": "Couldn't finish reflecting" + } + }; + + function talentLabel(target, status) { + const row = TALENT_LABELS[target]; + if (!row || !(status in row)) { + throw new Error("no chat talent label for target=" + target + " status=" + status); + } + return row[status]; + } + + window.solChatCopy = { + talentLabel, + CHAT_QUEUE_INDICATOR_SINGULAR: "1 message waiting", + CHAT_QUEUE_INDICATOR_PLURAL_FORMAT: "{count} messages waiting", + CHAT_QUEUE_DEPTH_CAP_MESSAGE: "Give sol a moment to catch up — you have 10 messages waiting." + }; +})(); diff --git a/solstone/convey/static/tests/chat-bar-copy.html b/solstone/convey/static/tests/chat-bar-copy.html new file mode 100644 index 000000000..fefe35850 --- /dev/null +++ b/solstone/convey/static/tests/chat-bar-copy.html @@ -0,0 +1,88 @@ + + + + + + + chat bar copy test + + + + + +
RUNNING chat bar copy
+
    + + + + diff --git a/solstone/convey/templates/app.html b/solstone/convey/templates/app.html index db0bd68c5..cafb46c44 100644 --- a/solstone/convey/templates/app.html +++ b/solstone/convey/templates/app.html @@ -23,6 +23,7 @@ +