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 @@
+