From 0338764b6211d21a93461a54daf313d54e24f8f2 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Tue, 15 Sep 2026 14:51:05 +0800 Subject: [PATCH 01/17] fix(session): stabilize compaction and timed message state - Keep Claude compaction and context usage aligned with native boundaries. - Preserve account-scoped controls and suppress phantom Codex sessions. - Track receipt-backed timed messages and display their sidebar activity. - Keep sidebar menus visible above mobile and footer boundaries. - Cover state transitions and UI behavior with regression tests and docs. --- AGENTS.md | 2 +- CHANGELOG.md | 13 + CHANGELOG_zh.md | 9 + README.md | 3 +- README_en.md | 3 +- cc_remote/protocol.py | 22 ++ cc_remote/timed_tasks.py | 322 ++++++++++++++++++++++ cc_remote/wrapper/claude_compaction.py | 42 +++ cc_remote/wrapper/claude_runtime.py | 2 +- cc_remote/wrapper/history_store.py | 16 +- cc_remote/wrapper/machine.py | 201 +++++++------- cc_remote/wrapper/sdk.py | 116 ++++++-- cc_remote/wrapper/stream.py | 60 +++- cc_remote/wrapper/work_context.py | 10 + docs/configuration.md | 2 +- docs/configuration_en.md | 2 +- docs/timed-messages.md | 53 ++++ tests/test_claude_autocompact.py | 61 ++-- tests/test_claude_compaction_flow.py | 182 ++++++++++++ tests/test_claude_compaction_history.py | 148 ++++++++++ tests/test_claude_permission_state.py | 26 +- tests/test_codex_profiles.py | 20 ++ tests/test_engine_versions.py | 8 +- tests/test_history_store.py | 40 +-- tests/test_timed_tasks.py | 187 +++++++++++++ web/package.json | 2 +- web/scripts/check-bundle-budget.mjs | 4 +- web/src/components/ChatView.tsx | 5 +- web/src/components/Composer.tsx | 4 +- web/src/components/ContextPopover.tsx | 14 +- web/src/components/ProcessTimeline.tsx | 10 + web/src/components/SessionCardMenu.tsx | 99 +++++++ web/src/components/SessionsSidebar.tsx | 13 +- web/src/components/TimedMessageTag.tsx | 10 + web/src/components/TimedTaskIndicator.tsx | 139 ++++++++++ web/src/components/timed-task.css | 45 +++ web/src/domain/conversation.ts | 2 + web/src/history-merge.ts | 1 + web/src/index.css | 16 +- web/src/notice-presentation.ts | 6 +- web/src/protocol.ts | 10 +- web/src/reducer.ts | 21 +- web/tests/auto-compact.test.ts | 60 +++- web/tests/history-browser.fixture.tsx | 14 + web/tests/history-browser.spec.ts | 237 ++++++++++++++++ web/tests/notices-rate-limits.test.ts | 38 ++- web/tests/timed-tasks.test.ts | 40 +++ 47 files changed, 2111 insertions(+), 229 deletions(-) create mode 100644 cc_remote/timed_tasks.py create mode 100644 cc_remote/wrapper/claude_compaction.py create mode 100644 docs/timed-messages.md create mode 100644 tests/test_claude_compaction_flow.py create mode 100644 tests/test_claude_compaction_history.py create mode 100644 tests/test_timed_tasks.py create mode 100644 web/src/components/SessionCardMenu.tsx create mode 100644 web/src/components/TimedMessageTag.tsx create mode 100644 web/src/components/TimedTaskIndicator.tsx create mode 100644 web/src/components/timed-task.css create mode 100644 web/tests/timed-tasks.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2c01638e..fb1c1a4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ attachment and optional App-control MCP tools are separate user choices. interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards the exact verified patch at startup). - **Claude Code is the user's daily CLI, not the SDK bundle**: Claude Code - `>=2.1.258` is required and checked before a Claude session starts. The wrapper + `>=2.1.263` is required and checked before a Claude session starts. The wrapper defaults `CLAUDE_BIN` to `~/.local/bin/claude` and passes that path explicitly to the SDK. An empty value keeps this default; only another absolute path may override it. Keep that CLI updated and signed in before starting the wrapper. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c87142e..421b8818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ TUI editing, cancellation and ordering controls. Reject stale queue snapshots and changes while a message is starting. Deploy Relay, Web, Wrapper and TUI together; protocol v66 clients cannot connect to protocol v67 services. +- Track Claude compaction through native status and boundary events, preserve + valid context readings during refresh failures, and show the applied automatic + compaction threshold separately from model capacity. Hide internal command + messages and keep completed-turn timestamps stable when rebuilding history. + Require Claude Code 2.1.263 or newer for the verified control APIs. +- Keep account-specific Claude controls attached to the correct session and + omit transient Codex thread notifications that have no readable history from + the session list. +- Show explicit timed-message tags, a rounded moving session outline and the + next scheduled send time. Tasks use account-scoped native queue receipts + independently of Goals (protocol v67). Session menus and task popovers + avoid the sidebar footer and the mobile visual viewport. + - Backport shared improvements from the DSH branch without adding a third engine (protocol v66): rounded Claude/Codex Goal dialogs with native save confirmation and mobile keyboard recovery; directory links open `/open`, and diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index e14492c4..085f63e7 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -13,6 +13,15 @@ - 新增服务端队列原子排序(protocol v67),TUI 编辑、取消及排序操作支持配置 快捷键。拒绝基于过期队列的排序和消息启动期间的调整。Relay、Web、Wrapper 与 TUI 必须一起升级;protocol v66 客户端不能连接 protocol v67 服务。 +- 按 Claude 原生状态与压缩边界更新压缩动画;上下文刷新失败时保留有效读数, + 分别显示生效的自动压缩阈值和模型容量。历史中隐藏内部命令消息,避免后续压缩 + 改变已完成回答的时间戳。已验证的控制接口要求 Claude Code 2.1.263 或更高版本。 +- 修复多账号 Claude 会话控制的持久化归属;Codex 临时线程通知尚无可读历史时, + 不再生成点开后为空的会话列表项。 +- 定时消息增加来源标签;待发送任务的会话卡片显示圆角流动光圈及下次发送时间。 + 按账号和原生队列回执关联,与 Goal 分开(protocol v67);会话菜单和任务小窗 + 自动避让侧栏底栏及手机可视区域。 + - 整合 DSH 分支中的通用改进,保留 Claude/Codex 双引擎(protocol v66):圆角 Goal 小窗等待原生保存确认,恢复手机键盘收起后的布局;目录链接接入 `/open`, XLSX 可预览已保存的单元格、切换工作表并下载原文件。 diff --git a/README.md b/README.md index d6ba458c..bc8d187f 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Wrapper 主动出站连接 Relay,设备不需要开放公网入站端口。Rel 至少准备一个可用引擎: -- **Claude**:日常 Claude Code `>= 2.1.258`,默认路径 `~/.local/bin/claude`。 +- **Claude**:日常 Claude Code `>= 2.1.263`,默认路径 `~/.local/bin/claude`。 Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.151`。 - **Codex**:已登录的官方 CLI。共享控制需要同时支持 `codex app-server daemon --help` 和 `codex app-server proxy --help`。 @@ -265,6 +265,7 @@ Code 默认权限较宽;Work 的私有目录策略不能替代独立系统用 | [远程 Viewer](docs/remote-viewer.md) | 交互式静态页面、Bridge/Isolated 模式 | | Codex App 接入:[macOS](docs/codex-desktop-launcher.md)/[Linux](docs/codex-desktop-linux.md) | 可选桌面 App、日常 CLI 与 Wrapper 共用 daemon | | [Codex App 工具](docs/codex-app-tools.md) | 可选 App-control MCP | +| [定时消息 UI](docs/timed-messages.md) | 定时发送入口、消息标签、光圈与下次时间 | | [更新记录](CHANGELOG_zh.md) | 版本变化与迁移记录 | ## 终端工作台(预览) diff --git a/README_en.md b/README_en.md index 60e66b77..2239db30 100644 --- a/README_en.md +++ b/README_en.md @@ -167,7 +167,7 @@ development/builds use **Python 3.13 and Node 24**, matching CI and [`.nvmrc`](. Prepare at least one working engine: -- **Claude:** daily Claude Code `>= 2.1.258`, normally at `~/.local/bin/claude`. +- **Claude:** daily Claude Code `>= 2.1.263`, normally at `~/.local/bin/claude`. Wrapper launches that CLI; the Python Agent SDK is pinned to `0.2.151`. - **Codex:** an authenticated official CLI. Shared control requires both `codex app-server daemon --help` and `codex app-server proxy --help`. @@ -304,6 +304,7 @@ policy is not a replacement for separate OS users, containers or virtual machine | [Remote Viewer](docs/remote-viewer.md) | Interactive static pages, Bridge/Isolated modes | | Codex App: [macOS](docs/codex-desktop-launcher.md) / [Linux](docs/codex-desktop-linux.md) | Optional App, daily CLI and Wrapper on one daemon | | [Codex App tools](docs/codex-app-tools.md) | Optional App-control MCP | +| [Timed messages](docs/timed-messages.md) | Scheduled queue receipts, message tags and countdown UI | | [Changelog](CHANGELOG.md) | Version changes and migrations | ## Terminal workspace (preview) diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py index 60e0cfdf..0e8a258b 100644 --- a/cc_remote/protocol.py +++ b/cc_remote/protocol.py @@ -834,6 +834,25 @@ class BtwClosed(_Base): revision: int = Field(ge=0, le=9_007_199_254_740_991) +class TimedMessage(BaseModel): + """Explicit local scheduler receipt, never inferred from message text.""" + model_config = ConfigDict(extra="forbid") + task_id: WireId + title: str = Field(min_length=1, max_length=120) + scheduled_at: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS) + + +class TimedTaskInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + task_id: WireId + title: str = Field(min_length=1, max_length=120) + next_message_at: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS) + interval_seconds: float = Field(ge=1, le=31_536_000) + sent_count: int = Field(ge=0, le=1000) + total_count: int = Field(ge=1, le=1000) + valid_until: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS) + + class UserMsg(_Base): """A user's query, broadcast to all clients so every device sees the full conversation (prompt + response). The originating client dedups by msg_id @@ -846,6 +865,7 @@ class UserMsg(_Base): # a source-derived id. Carry both so a history-first race can deduplicate # the later live echo. client_msg_id: Optional[WireId] = None + timed_task: Optional[TimedMessage] = None prompt: str images: Optional[list[QueryImage]] = Field(default=None, max_length=MAX_ATTACHMENT_COUNT) # Metadata only: file bodies stay out of replay/cache, while names remain @@ -1184,6 +1204,7 @@ class SessionInfo(BaseModel): """A row in the sessions sidebar (subset of SDK SDKSessionInfo).""" model_config = ConfigDict(extra="forbid") session_id: WireId + timed_tasks: list[TimedTaskInfo] = Field(default_factory=list, max_length=32) summary: Optional[str] = None last_modified: Optional[str] = None first_prompt: Optional[str] = None @@ -2430,6 +2451,7 @@ class ConversationTurn(BaseModel): model_config = ConfigDict(extra="forbid") id: WireId clientMsgId: Optional[WireId] = None + timedTask: Optional[TimedMessage] = None prompt: str = Field(default="", max_length=128 * 1024) blocks: list[dict[str, Any]] = Field(default_factory=list, max_length=32) done: bool = False diff --git a/cc_remote/timed_tasks.py b/cc_remote/timed_tasks.py new file mode 100644 index 00000000..2eae6bc8 --- /dev/null +++ b/cc_remote/timed_tasks.py @@ -0,0 +1,322 @@ +"""Local scheduled-message receipts; prompts and account paths stay on the host. + +The clock is a detached helper, not a Goal. Delivery uses the existing official +Codex daemon's queue and an explicit client message id. Reads never start an +engine, and ambiguous delivery is never retried automatically. +""" +from __future__ import annotations + +import argparse +import asyncio +from contextlib import contextmanager +import fcntl +import json +import os +from pathlib import Path +import sqlite3 +import stat +import subprocess +import sys +import time +from uuid import UUID, uuid4 + +from websockets.asyncio.client import unix_connect + +MAX_TASKS = 32 +MAX_SENDS = 1000 +LEASE_SECONDS = 90 + + +class TimedTaskStore: + def __init__(self, state_dir: Path): + self.path = Path(state_dir) / "timed-tasks.sqlite3" + + @contextmanager + def connect(self, *, write=False): + if write: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if self.path.exists() or self.path.is_symlink(): + info = self.path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid(): + raise ValueError("Invalid task store") + elif not write: + yield None + return + db = sqlite3.connect(self.path.as_uri() + ("?mode=rwc" if write else "?mode=ro"), + uri=True, timeout=1) + db.row_factory = sqlite3.Row + try: + if write: + os.chmod(self.path, 0o600) + db.executescript(""" + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, home TEXT NOT NULL, sid TEXT NOT NULL, + title TEXT NOT NULL, prompt TEXT NOT NULL, + interval REAL NOT NULL, count INTEGER NOT NULL, + sent INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL, + next_at REAL, lease_until REAL NOT NULL, updated REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS task_owner ON tasks(home,sid,updated); + CREATE TABLE IF NOT EXISTS deliveries ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, scheduled_at REAL NOT NULL, + native_id TEXT, turn_id TEXT, accepted INTEGER NOT NULL DEFAULT 0, + UNIQUE(task_id, ordinal) + ); + CREATE INDEX IF NOT EXISTS delivery_task ON deliveries(task_id); + CREATE INDEX IF NOT EXISTS delivery_native ON deliveries(native_id); + CREATE INDEX IF NOT EXISTS delivery_turn ON deliveries(turn_id); + """) + else: + db.execute("PRAGMA query_only=ON") + yield db + if write: + db.commit() + finally: + db.close() + + def create(self, home: str, sid: str, title: str, prompt: str, + delay: float, interval: float, count: int) -> str: + UUID(sid) + if not (1 <= count <= MAX_SENDS and 1 <= delay <= 31_536_000 + and 1 <= interval <= 31_536_000 and 0 < len(prompt) <= 32_768 + and 0 < len(title) <= 120): + raise ValueError("Invalid scheduled message") + task_id, now = str(uuid4()), time.time() + home = str(Path(home).expanduser().resolve(strict=True)) + with self.connect(write=True) as db: + db.execute("BEGIN IMMEDIATE") + active = db.execute("SELECT count(*) FROM tasks WHERE home=? AND sid=? " + "AND state='running' AND lease_until>?", (home, sid, now)).fetchone()[0] + if active >= MAX_TASKS: + raise ValueError("Too many active scheduled tasks") + db.execute("INSERT INTO tasks VALUES(?,?,?,?,?,?,?,0,'running',?,?,?)", + (task_id, home, sid, title, prompt, interval, count, + now + delay, now + LEASE_SECONDS, now)) + return task_id + + def get(self, task_id: str) -> dict | None: + with self.connect() as db: + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() if db else None + return dict(row) if row else None + + def heartbeat(self, task_id: str) -> bool: + now = time.time() + with self.connect(write=True) as db: + return bool(db.execute("UPDATE tasks SET lease_until=?,updated=? " + "WHERE id=? AND state='running'", + (now + LEASE_SECONDS, now, task_id)).rowcount) + + def finish(self, task_id: str, state: str) -> None: + if state not in {"completed", "failed", "cancelled", "unknown"}: + raise ValueError("Invalid terminal task state") + with self.connect(write=True) as db: + db.execute("UPDATE tasks SET state=?,next_at=NULL,updated=? WHERE id=?", + (state, time.time(), task_id)) + + def begin_delivery(self, task_id: str) -> str: + delivery = str(uuid4()) + with self.connect(write=True) as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + if not row or row["state"] != "running" or row["sent"] >= row["count"]: + raise ValueError("Task is no longer sending") + # Persist before I/O. A crashed/ambiguous attempt at this ordinal + # cannot be sent again by a restarted helper. + db.execute("INSERT INTO deliveries(id,task_id,ordinal,scheduled_at) VALUES(?,?,?,?)", + (delivery, task_id, row["sent"], row["next_at"])) + return delivery + + def accepted(self, task_id: str, delivery: str, turn_id: str | None = None) -> None: + with self.connect(write=True) as db: + db.execute("BEGIN IMMEDIATE") + changed = db.execute("UPDATE deliveries SET accepted=1,turn_id=COALESCE(?,turn_id) " + "WHERE id=? AND task_id=? AND accepted=0", + (turn_id, delivery, task_id)).rowcount + if changed: + now = time.time() + db.execute("UPDATE tasks SET sent=sent+1," + "state=CASE WHEN state='running' AND sent+1>=count THEN 'completed' ELSE state END," + "next_at=CASE WHEN sent+1>=count THEN NULL ELSE MAX(next_at+interval,?+interval) END," + "updated=? WHERE id=?", (now, now, task_id)) + + def active(self, *, now: float | None = None) -> dict[tuple[str, str], list[dict]]: + """One bounded read for the sidebar; private ownership keys stay local.""" + now = time.time() if now is None else now + with self.connect() as db: + rows = db.execute("SELECT * FROM tasks WHERE state='running' AND lease_until>? " + "AND sent list[dict]: + now = time.time() if now is None else now + with self.connect() as db: + if db is None: + return [] + rows = db.execute("SELECT * FROM tasks WHERE home=? AND sid=? AND state='running' " + "AND lease_until>? AND sent dict | None: + ids = list(dict.fromkeys(v for v in ids if isinstance(v, str) and 0 < len(v) <= 512))[:4] + if not ids: + return None + marks = ",".join("?" for _ in ids) + with self.connect(write=bind is not None and self.path.exists()) as db: + if db is None: + return None + rows = db.execute(f"SELECT d.*,t.title FROM deliveries d JOIN tasks t ON t.id=d.task_id " + f"WHERE t.home=? AND t.sid=? AND (d.id IN ({marks}) " + f"OR d.native_id IN ({marks}) OR d.turn_id IN ({marks})) LIMIT 2", + (home, sid, *ids, *ids, *ids)).fetchall() + if len(rows) != 1: + return None + row = rows[0] + if bind and row["native_id"] is None: + db.execute("UPDATE deliveries SET native_id=? WHERE id=?", (bind, row["id"])) + return {"task_id": row["task_id"], "title": row["title"], + "scheduled_at": row["scheduled_at"]} + + +class NativeQueueError(RuntimeError): + pass + + +async def deliver(task: dict, delivery_id: str, on_accepted) -> None: + """Queue on the existing account daemon, without resuming or creating a thread.""" + socket = Path(task["home"]) / "app-server-control/app-server-control.sock" + info = socket.lstat() + if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid(): + raise NativeQueueError("Shared daemon unavailable") + async with asyncio.timeout(30), unix_connect( + str(socket), uri="ws://localhost/rpc", compression=None, + max_size=2**20, open_timeout=5, close_timeout=1, + ) as ws: + seq = 0 + + async def rpc(method, params): + nonlocal seq + seq += 1 + await ws.send(json.dumps({"id": seq, "method": method, "params": params})) + while True: + message = json.loads(await ws.recv()) + if message.get("id") == seq: + if "error" in message: + raise NativeQueueError("Native queue request rejected") + return message["result"] + + await rpc("initialize", {"clientInfo": {"name": "cc-remote-timed-task", "version": "1"}, + "capabilities": {"experimentalApi": True}}) + await ws.send(json.dumps({"method": "initialized"})) + await rpc("thread/read", {"threadId": task["sid"], "includeTurns": False}) + reply = await rpc("thread/queue/add", {"threadId": task["sid"], + "input": [{"type": "text", "text": task["prompt"], "text_elements": []}], + "clientUserMessageId": delivery_id}) + queued = reply["queuedSubmission"] + if queued.get("clientUserMessageId") != delivery_id: + raise NativeQueueError("Unconfirmed scheduled message identity") + on_accepted() + # Queue acceptance is durable. An idle thread can start now; otherwise + # the official queue waits for its native current-turn boundary. + try: + state = await rpc("thread/read", {"threadId": task["sid"], "includeTurns": False}) + if state["thread"]["status"]["type"] == "idle": + await rpc("thread/queue/start", { + "threadId": task["sid"], "queuedSubmissionId": queued["id"]}) + return + except Exception: + # Another client may already have consumed this exact queue entry. + # Do not add it again or substitute an ordinary turn/start. + pass + return None + + +async def run_task(store: TimedTaskStore, task_id: str) -> None: + UUID(task_id) + # One helper owns a task. The persistent delivery ordinal additionally + # fences retries after process death or an uncertain socket response. + lock_path = store.path.parent / f"timed-task-{task_id}.lock" + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + await _run_task(store, task_id) + finally: + os.close(fd) + + +async def _run_task(store: TimedTaskStore, task_id: str) -> None: + while task := store.get(task_id): + if task["state"] != "running": + return + if task["sent"] >= task["count"]: + store.finish(task_id, "completed") + return + if not store.heartbeat(task_id): + return + delay = task["next_at"] - time.time() + if delay > 0: + await asyncio.sleep(min(delay, 20)) + continue + try: + delivery_id = store.begin_delivery(task_id) + await deliver(task, delivery_id, lambda: store.accepted(task_id, delivery_id)) + except Exception: + current = store.get(task_id) + if current and current["state"] == "running" and current["sent"] == task["sent"]: + store.finish(task_id, "unknown") + return + + +def main() -> None: + parser = argparse.ArgumentParser(description="Scheduled messages to an existing Codex session") + parser.add_argument("--state-dir", type=Path, default=Path.home() / ".cc-remote") + commands = parser.add_subparsers(dest="command", required=True) + start = commands.add_parser("start") + start.add_argument("--codex-home", type=Path, required=True) + start.add_argument("--thread", required=True) + start.add_argument("--title", default="定时任务") + start.add_argument("--message", required=True) + start.add_argument("--after", type=float, required=True, help="Seconds until the first message") + start.add_argument("--every", type=float, default=60, help="Seconds between messages") + start.add_argument("--count", type=int, default=1) + for name in ("run", "cancel", "status"): + commands.add_parser(name).add_argument("task_id") + args = parser.parse_args() + store = TimedTaskStore(args.state_dir.expanduser().resolve()) + if args.command == "start": + task_id = store.create(str(args.codex_home), args.thread, args.title, args.message, + args.after, args.every, args.count) + try: + subprocess.Popen([sys.executable, "-m", "cc_remote.timed_tasks", "--state-dir", + str(store.path.parent), "run", task_id], + cwd=Path(__file__).resolve().parent.parent, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + store.finish(task_id, "failed") + raise + print(json.dumps({"task_id": task_id, "next_message_at": store.get(task_id)["next_at"]})) + elif args.command == "run": + asyncio.run(run_task(store, args.task_id)) + elif args.command == "cancel": + store.finish(args.task_id, "cancelled") + else: + task = store.get(args.task_id) + print(json.dumps({k: v for k, v in (task or {}).items() if k not in {"home", "prompt", "sid"}})) + + +if __name__ == "__main__": + main() diff --git a/cc_remote/wrapper/claude_compaction.py b/cc_remote/wrapper/claude_compaction.py new file mode 100644 index 00000000..ee9ce39f --- /dev/null +++ b/cc_remote/wrapper/claude_compaction.py @@ -0,0 +1,42 @@ +"""Normalize native Claude compact metadata from live and persisted events.""" +from __future__ import annotations + +from typing import Any + +from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER + + +def compact_metadata(row: object) -> dict[str, Any]: + """Use the SDK's snake case while accepting the transcript's camel case.""" + if not isinstance(row, dict): + return {} + metadata = row.get("compact_metadata", row.get("compactMetadata")) + if not isinstance(metadata, dict): + return {} + result: dict[str, Any] = {} + trigger = metadata.get("trigger") + if isinstance(trigger, str) and trigger in {"auto", "manual"}: + result["trigger"] = trigger + for name, alias in ( + ("pre_tokens", "preTokens"), + ("post_tokens", "postTokens"), + ("duration_ms", "durationMs"), + ): + value = metadata.get(name, metadata.get(alias)) + if (isinstance(value, int) and not isinstance(value, bool) + and 0 <= value <= MAX_SAFE_WIRE_INTEGER): + result[name] = value + return result + + +def compact_context_usage(row: object) -> dict[str, int] | None: + """Read a main-session boundary without counting the summarizer's input.""" + if (not isinstance(row, dict) + or row.get("type") != "system" + or row.get("subtype") != "compact_boundary" + or row.get("isSidechain") is True + or row.get("parentToolUseID") is not None + or row.get("parent_tool_use_id") is not None): + return None + post_tokens = compact_metadata(row).get("post_tokens") + return {"totalTokens": post_tokens} if post_tokens is not None else None diff --git a/cc_remote/wrapper/claude_runtime.py b/cc_remote/wrapper/claude_runtime.py index f16e7fae..5501150f 100644 --- a/cc_remote/wrapper/claude_runtime.py +++ b/cc_remote/wrapper/claude_runtime.py @@ -17,7 +17,7 @@ VERIFIED_SDK_VERSION = "0.2.151" -MINIMUM_CLAUDE_CLI_VERSION = "2.1.258" +MINIMUM_CLAUDE_CLI_VERSION = "2.1.263" _CLI_VERSION_TIMEOUT = 3.0 _VERSION_RE = re.compile( r"(? None: with self._connect() as connection: current = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if current in range(10, 39): + # Native /compact envelopes are not human turns. Rebuild the + # Claude narrative and cached visible-user graph together. + for table in ("history_pages", "history_turn_details"): + connection.execute(f"DELETE FROM {table} WHERE engine='claude'") + for table in ( + "claude_compact_sources", "claude_compact_records", "claude_compact_queue", + ): + connection.execute(f"DROP TABLE IF EXISTS {table}") if current in range(10, 38): # RPC-accepted steers could cache a phantom process. Invalidate # only derived Codex narrative; retain other engines and assets. @@ -1407,8 +1417,8 @@ def _ensure_schema(self) -> None: for table in ("history_pages", "history_turn_details"): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") - elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): - # The independent v22-v38 invalidations above suffice. + elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38): + # The independent v22-v39 invalidations above suffice. pass elif current not in (0, _SCHEMA_VERSION): # v9 changes the invariant of history_turn_details: those rows diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index 7d23b5b0..c07a0f82 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -101,18 +101,19 @@ ) from cc_remote.audio_preview import AUDIO_PREVIEW_MEDIA_TYPES, validate_audio_preview from cc_remote.log import logger +from cc_remote.timed_tasks import TimedTaskStore from cc_remote.workspaces import ( WORK_UPLOADS_MARKER, WORK_UPLOADS_MARKER_PAYLOAD, WorkStores, ) from cc_remote.protocol import ( + TimedMessage, ASK_OPTION_MAX_COUNT, ARTIFACT_PREVIEW_MAX_BYTES, FILE_PREVIEW_MAX_BYTES, MAX_BACKGROUND_PROCESS_COMMAND_CHARS, MAX_BACKGROUND_PROCESS_CWD_CHARS, MAX_BACKGROUND_PROCESS_ITEMS, MAX_BACKGROUND_PROCESS_SUMMARY_CHARS, - MIN_AUTO_COMPACT_TOKENS, MAX_SAFE_WIRE_INTEGER, MAX_QUERY_QUEUE_BYTES, MAX_QUERY_QUEUE_ITEMS, PREVIEW_ASSET_MAX_BYTES, Error, Hello, Query, QueryQueueState, QueuedQueryDetail, QueuedQueryInfo, @@ -2012,6 +2013,8 @@ class WrapperMachine: def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): self.cfg = cfg + self._timed_tasks = TimedTaskStore(cfg.state_dir) + self._timed_task_catalog = {} self.transport = transport self._command_router = CommandRouter(self) self.instance_id = uuid4().hex @@ -7114,30 +7117,10 @@ def _claude_setting_needs_compaction( """Whether applying a smaller custom window first needs /compact.""" total = cls._claude_cached_context_total(ctx) target = event.threshold_tokens if event.mode == "custom" else None - if target is None: - # ``auto`` and ``inherit`` reveal their effective threshold only in - # the replacement child. A prior custom window may already hold - # more context than that unknown target. Avoid reconnecting into an - # oversized first request; only a known context below the smallest - # selectable threshold can safely skip the boundary. - return total is None or total >= MIN_AUTO_COMPACT_TOKENS - if total is not None: - return total >= target - current_threshold = ( - event.applied_threshold_tokens - if event.applied_mode == "custom" else - getattr(ctx.sdk, "effective_auto_compact_threshold_tokens", None) - ) - # Unknown usage while reducing a proven larger window is not permission - # to gamble the next provider request. Compact conservatively once. - if ( - isinstance(current_threshold, int) - and not isinstance(current_threshold, bool) - ): - return current_threshold >= target - # ``inherit``/``auto`` without a successful control reading is an - # unknown upper bound, not proof that the current context fits. - return event.applied_mode in {"inherit", "auto", None} + # Missing usage/capacity is not evidence of overflow. Apply the native + # CLI option without issuing a speculative model call; Claude will + # enforce its own threshold when the next normal turn starts. + return total is not None and target is not None and total >= target async def _compact_managed_claude_context( self, @@ -7179,17 +7162,13 @@ async def _compact_managed_claude_context( native_sid = extract_session_id(message) if native_sid and not ctx.session_id: await self._capture_session_id(ctx, native_sid) - if ( - isinstance(message, SystemMessage) - and message.subtype == "compact_boundary" - ): - for event in translator.feed(message): - if ( - isinstance(event, ProcessEvent) - and event.kind == "compaction" - ): + for event in translator.feed(message): + if (isinstance(event, ProcessEvent) + and event.kind == "compaction"): + if (isinstance(message, SystemMessage) + and message.subtype == "compact_boundary"): compact_event = event - await self._emit(ctx, event) + await self._emit(ctx, event) if isinstance(message, ResultMessage): terminal = message break @@ -7200,7 +7179,7 @@ async def _compact_managed_claude_context( release_background() ctx.claude_write_active = False if ctx.session_id: - self._resync_watch(ctx.session_id) + self._resync_watch(self._ctx_wire_sid(ctx)) if terminal is None: raise RuntimeError("Claude compact ended without ResultMessage") @@ -7216,7 +7195,10 @@ async def _compact_managed_claude_context( raise RuntimeError( "Claude reported compact success without compact_boundary") - self._invalidate_claude_context_usage(ctx) + # SdkHandle already replaced the old count at the native boundary. + # Do not discard its post-compaction sample again after the result. + if not isinstance(ctx.sdk, SdkHandle): + self._invalidate_claude_context_usage(ctx) ctx.claude_compaction_revision += 1 log.info( "Claude native context compacted", @@ -7326,8 +7308,8 @@ async def _apply_pending_claude_auto_compact( except Exception as compact_exc: ctx.auto_compact_phase = "blocked" ctx.auto_compact_error = ( - "当前上下文高于目标窗口,但原生压缩未产生有效边界;" - "仍保留原阈值,未发送新的模型请求。可以 Fork/新建会话后继续。" + "尚未确认原生压缩完成,暂时保留原阈值。" + "请检查压缩结果后重试。" ) await self._persist_claude_session_controls(ctx) event, _ = await self._publish_claude_auto_compact( @@ -7529,7 +7511,7 @@ async def _persist_claude_session_controls(self, ctx: SessionContext) -> None: # their engine mutations in either order; each durable write must # therefore reflect the newest complete SDK state at its own # serialized boundary, never a snapshot captured while waiting. - session_id = ctx.session_id + session_id = self._ctx_wire_sid(ctx) if (ctx.engine != "claude" or not session_id or ctx.btw or getattr(ctx.sdk, "is_claude_broker", False) or (ctx.key is not None @@ -7597,7 +7579,7 @@ async def _persist_claude_session_controls(self, ctx: SessionContext) -> None: # keeps the user's desired value until compact + reconnect make # it safe to publish here as applied. await self._claude_broker.set_preferences( - session_id, + ctx.session_id, model=model, effort=effort, permission_mode=permission_mode, @@ -10222,6 +10204,16 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None: # treats an absent ``to`` as broadcast, so fail closed for an impossible # ownerless fork rather than leaking its contents. msg.sid = self._ctx_wire_sid(ctx) or ctx.key + if isinstance(msg, UserMsg) and ctx.engine == "codex" and not ctx.btw: + try: + profile, native_sid = self._codex_target(msg.sid) + source = await asyncio.to_thread( + self._timed_tasks.source, str(profile.home), native_sid, + [msg.msg_id, msg.client_msg_id], bind=msg.msg_id) + if source: + msg.timed_task = TimedMessage(**source) + except Exception: + log.warning("Timed message receipt unavailable", sid=msg.sid) if ctx.btw: if not ctx.owner_client_id: log.error("dropping frame for ownerless btw", sid=ctx.key, @@ -13859,6 +13851,7 @@ async def _watch_loop(self) -> None: and ctx.session_id not in self._watch): self._watch_session(ctx.session_id) await self._poll_watches_once() + await self._refresh_timed_tasks() except asyncio.CancelledError: raise except Exception: @@ -13900,6 +13893,53 @@ async def _build_history( self, sid: str, before=None, limit=None, cwd_hint=None, detail: str = "full", *, allow_stale: bool = False, + ) -> History: + history = await self._build_history_source( + sid, before, limit, cwd_hint, detail, allow_stale=allow_stale) + return await self._annotate_timed_history(sid, history) + + async def _annotate_timed_history(self, sid: str, history: History) -> History: + """Overlay local receipts regardless of the native history provider.""" + ctx = self._ctx_by_sid(sid) + if ctx is not None and ctx.engine != "codex": + return history + try: + profile, native_sid = self._codex_target(sid) + def annotate(): + for turn in history.turns: + source = self._timed_tasks.source( + str(profile.home), native_sid, [turn.id, turn.clientMsgId]) + if source: + turn.timedTask = TimedMessage(**source) + for event in history.events: + if event.get("type") != "user_msg": + continue + source = self._timed_tasks.source( + str(profile.home), native_sid, + [event.get("msg_id"), event.get("client_msg_id")]) + if source: + event["timed_task"] = source + await asyncio.to_thread(annotate) + except Exception: + log.warning("Timed history receipts unavailable", sid=sid) + return history + + async def _refresh_timed_tasks(self) -> dict: + try: + current = await asyncio.to_thread(self._timed_tasks.active) + except Exception: + # Receipt I/O must not disrupt ordinary transcript mirroring. + return self._timed_task_catalog + if current != self._timed_task_catalog: + self._timed_task_catalog = current + await self._invalidate_session_list("codex", "code") + await self._invalidate_session_list("codex", "work") + return current + + async def _build_history_source( + self, sid: str, before=None, limit=None, cwd_hint=None, + detail: str = "full", + *, allow_stale: bool = False, ) -> History: """Read a session's transcript and assemble ONE History frame. Shared by GetHistory (routed to the requester) and the watcher (broadcast on external @@ -15932,6 +15972,7 @@ async def with_terminal_snapshot(history: History) -> History: try: history = await self._build_official_codex_history( sid, before=before, limit=limit) + history = await self._annotate_timed_history(sid, history) if ( not _background and before is None and history.authoritative is False @@ -20751,6 +20792,9 @@ async def reconnect_if_still_quiescent() -> bool: reason=reason, fork=fork, ) + # One recovery is enough. A normal ResultMessage re-enables + # inspection; reopening the popover must not restart again. + expected_sdk.context_probe_suppressed = True except Exception as reconnect_exc: log.warning( "Claude context timeout recovery failed", @@ -20818,8 +20862,7 @@ async def _handle_get_context_locked( recent_reader = getattr( ctx.sdk, "cached_recent_context_usage", None) recent = recent_reader() if callable(recent_reader) else None - if (not _has_claude_context_total(recent) - or recent.get("totalTokens") == 0): + if not _has_claude_context_total(recent): recent = None # A successful generation probe is newer than the transcript # it resumed. Read disk only when no exact/live cache exists. @@ -20843,6 +20886,7 @@ async def _handle_get_context_locked( refresh = bool( getattr(cmd, "refresh", False) and not prefer_cached_claude + and not getattr(ctx.sdk, "context_probe_suppressed", False) ) refresh_readiness: Literal[ "ready", "busy", "external", "stale", "changed" @@ -20945,20 +20989,18 @@ async def _handle_get_context_locked( ) ) work_claimed = self._claude_context_work_active(ctx) - error = Error( - code=ERR_BUSY if work_claimed else ERR_INTERNAL, - message=( - "Claude 已开始处理后台任务结果,将在空闲后重试。" - if work_claimed else - "上下文读取超时,Claude 会话已安全恢复,请重试。" - if poisoned and recovered else - "上下文读取暂不可用,请稍后重试。" - ), - request_id=getattr(cmd, "cmd_id", None), - to=getattr(cmd, "client_id", None), - ) - await self._emit(ctx, error) - return error + if work_claimed or poisoned and not recovered: + error = Error( + code=ERR_BUSY if work_claimed else ERR_INTERNAL, + message="上下文读取暂不可用,请稍后重试。", + request_id=getattr(cmd, "cmd_id", None), + to=getattr(cmd, "client_id", None), + ) + await self._emit(ctx, error) + return error + # A failed metadata read does not invalidate a valid + # sample. Publish the cached reading below, keeping + # its source and without surfacing a global error. else: context_source = "control" exact = refreshed_usage @@ -26445,41 +26487,11 @@ async def read(profile: CodexProfile): item["summary"] = "派生会话" normalized.append(item) - materialized_native_ids = { - row.get("native_session_id") - for row in normalized - if isinstance(row.get("native_session_id"), str) - } - # ``thread/started`` is an account-scoped, source-filtered native - # event. During the very small state-DB commit gap it is sufficient - # to keep a fresh CLI row discoverable; older hints remain gated by - # an exact DB hit so an externally deleted thread cannot live forever. - for native_sid, candidate_info in profile_candidates.items(): - if ( - native_sid in materialized_native_ids - or not candidate_info.get("hint_fresh") - ): - continue - try: - wire_sid = self._codex_wire_sid(profile, native_sid) - except ValueError: - continue - normalized.append({ - "session_id": wire_sid, - "native_session_id": native_sid, - "summary": "新会话", - "first_prompt": None, - "cwd": None, - "last_modified": str(time.time()), - "git_branch": None, - "forked_from_id": candidate_info.get("forked_from_id"), - "status": None, - "tag": None, - "codex_profile_id": profile.id, - "codex_profile_label": profile.label, - _CODEX_CATALOG_PRIORITY: 2, - }) - materialized_native_ids.add(native_sid) + # A sibling thread/started is only a discovery hint. Native + # ephemeral helpers can emit it without ever creating a resumable + # rollout. Keep the exact DB repair above, but never manufacture a + # clickable row from the hint alone. Resident threads below carry + # their own live handle and remain visible during catalog lag. # A just-started resident is stronger evidence than a lagging DB # projection: thread/start already returned its durable id and this @@ -26761,6 +26773,7 @@ async def _send_codex_session_list( # BTW fork can finish connecting during one of those awaits, so # close that race immediately before the synchronous wire build. raw = self._filter_private_codex_btw_rows(raw) + timed_catalog = await self._refresh_timed_tasks() sessions = [] for row in raw: wire_sid = row["session_id"] @@ -26772,6 +26785,8 @@ async def _send_codex_session_list( continue sessions.append(SessionInfo( session_id=wire_sid, + timed_tasks=timed_catalog.get(( + str(self._codex_target(wire_sid)[0].home), native_sid), []), summary=(record.title if record and record.title else row.get("summary")), first_prompt=row.get("first_prompt"), @@ -38663,7 +38678,7 @@ async def freeze_indeterminate_recovery() -> None: # preflight/query/reader failures must not hide an external append. ctx.claude_write_active = False if not is_codex and claude_turn_completed and ctx.session_id: - self._resync_watch(ctx.session_id) + self._resync_watch(self._ctx_wire_sid(ctx)) if temp_dir is not None and not persistent_attachments: try: shutil.rmtree(temp_dir) diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index e4627b6d..0b8660ed 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -48,6 +48,7 @@ ) from cc_remote.wrapper.claude_runtime import inspect_claude_runtime from cc_remote.wrapper.claude_model_fallback import model_fallback_event +from cc_remote.wrapper.claude_compaction import compact_context_usage, compact_metadata from cc_remote.wrapper.claude_controls import ( CLAUDE_DEFAULT_AUTO_COMPACT_MODE, claude_auto_compact_cli_value, @@ -82,7 +83,7 @@ "claude-mythos-5-1[1m]": "claude-mythos-5-1[1m]", } _CONVERSATION_REWIND_PROBE_UUID = "00000000-0000-0000-0000-000000000000" -_CONTEXT_CONTROL_TIMEOUT = 15.0 +_CONTEXT_CONTROL_TIMEOUT = 60.0 _CONTEXT_STARTUP_TIMEOUT = 5.0 _CURRENT_LAUNCH_VALUE = object() @@ -253,10 +254,9 @@ def __init__( # process and stdout reader still look alive. self.control_plane_failed = False # Compatibility/public state for adapters which expose whether context - # inspection is temporarily unavailable. A timed-out generation is now - # replaced instead of quarantining its successor until another model - # turn: the replacement skips only its eager startup probe and is safe - # for an explicit user read immediately. + # inspection is temporarily unavailable. After a hard timeout, keep the + # replacement usable for normal work and pause optional inspection + # until a successful turn proves normal traffic has resumed. self.context_probe_suppressed = False self._last_context_usage: dict[str, Any] | None = None # Unlike the rich control response, the latest main-chain assistant @@ -315,6 +315,7 @@ def __init__( self._turn_background_release: asyncio.Event | None = None self._pending_turn_background_release: asyncio.Event | None = None self._pending_turn_origin_id: str | None = None + self._pending_compact = False self._message_pump_error: BaseException | None = None self._message_route_lock = asyncio.Lock() self._background_callbacks_pending = 0 @@ -566,6 +567,11 @@ async def connect( # ordinary same-session reconnect; explicit transcript mutations and # fresh forks invalidate it through invalidate_context_usage_cache(). self._last_context_usage = None + if self._last_recent_context_usage is not None: + # Counts survive a same-session reconnect; the old capacity does + # not (the reconnect may be applying a different native window). + self._last_recent_context_usage = { + "totalTokens": self._last_recent_context_usage["totalTokens"]} self.effective_auto_compact_threshold_tokens = None self.raw_context_max_tokens = None self.control_plane_failed = False @@ -611,6 +617,7 @@ async def connect( _launch_auto_compact=launch_auto_compact, _launch_effort=launch_effort, ) + self.context_probe_suppressed = True return # Model readout is useful control state, but a semantic or # capability failure does not poison an otherwise live child. @@ -644,6 +651,7 @@ async def connect( async def _read_context_usage_control( self, *, timeout: float = _CONTEXT_CONTROL_TIMEOUT, + detail: str = "summary", ) -> dict: """Issue one bounded read on the pinned SDK control protocol.""" async with self._control_request_lock: @@ -652,16 +660,17 @@ async def _read_context_usage_control( client = self.client if client is None: raise RuntimeError("Claude SDK is not connected") - # The public helper hardcodes a 60-second timeout. A timeout cannot - # be cancelled in the child, so bound it tightly and poison this - # exact generation rather than letting later controls pile up. + # The pinned Python helper omits detail and defaults to full, + # which calls the provider's token-count API for each category. + # The ring needs the CLI's local summary, with the SDK's normal + # deadline. Keep a hard timeout guard for a truly stalled child. query = getattr(client, "_query", None) send_control = getattr(query, "_send_control_request", None) if not callable(send_control): raise RuntimeError("bounded model control request unavailable") try: usage = await send_control( - {"subtype": "get_context_usage"}, + {"subtype": "get_context_usage", "detail": detail}, timeout=timeout, ) except Exception as exc: @@ -719,14 +728,15 @@ def _record_context_usage( capture_work_baseline: bool = False, ) -> None: """Cache one successful reading and update generation metadata.""" - self._last_context_usage = dict(usage) total_tokens = usage.get("totalTokens") - if (isinstance(total_tokens, int) - and not isinstance(total_tokens, bool) - and 0 <= total_tokens <= MAX_SAFE_WIRE_INTEGER): + usable = (isinstance(total_tokens, int) + and not isinstance(total_tokens, bool) + and 0 <= total_tokens <= MAX_SAFE_WIRE_INTEGER) + if usable: # Only a semantically usable exact response supersedes the latest # live/transcript fallback. A malformed successful control envelope # must not erase the last truthful reading. + self._last_context_usage = dict(usage) self._last_recent_context_usage = None if update_model: model = usage.get("model") if isinstance(usage, dict) else None @@ -754,19 +764,18 @@ def _record_context_usage( # A session with no selection yet takes the branch above, which # is how a fresh session learns its provider-selected model. auto_threshold = usage.get("autoCompactThreshold") - self.effective_auto_compact_threshold_tokens = ( - auto_threshold - if isinstance(auto_threshold, int) - and not isinstance(auto_threshold, bool) - and auto_threshold >= 0 else None - ) + if (isinstance(auto_threshold, int) + and not isinstance(auto_threshold, bool) + and 0 <= auto_threshold <= MAX_SAFE_WIRE_INTEGER): + self.effective_auto_compact_threshold_tokens = auto_threshold + elif usable: + self.effective_auto_compact_threshold_tokens = None raw_max = usage.get("rawMaxTokens") - self.raw_context_max_tokens = ( - raw_max - if isinstance(raw_max, int) - and not isinstance(raw_max, bool) - and raw_max >= 0 else None - ) + if (isinstance(raw_max, int) and not isinstance(raw_max, bool) + and 0 <= raw_max <= MAX_SAFE_WIRE_INTEGER): + self.raw_context_max_tokens = raw_max + elif usable: + self.raw_context_max_tokens = None if (capture_work_baseline and self.work_mode and self.work_context_baseline_tokens is None): total_tokens = usage.get("totalTokens") @@ -803,18 +812,31 @@ def _observe_recent_context_usage(self, message: Any) -> None: self._last_recent_context_usage = recovered def _observe_context_boundary(self, message: Any) -> None: - """Invalidate cached usage at every real native compact boundary.""" + """Replace pre-compact counts while retaining this child's capacity.""" if ( isinstance(message, SystemMessage) and message.subtype == "compact_boundary" + and isinstance(message.data, dict) + and message.data.get("parent_tool_use_id") is None + and message.data.get("parentToolUseID") is None + and message.data.get("isSidechain") is not True ): - self.invalidate_context_usage_cache() + capacity = { + key: value for key, value in (self._last_context_usage or {}).items() + if key in {"maxTokens", "rawMaxTokens", "autoCompactThreshold", + "isAutoCompactEnabled", "model"} + } + self._last_context_usage = None + usage = compact_context_usage(message.data) + self._last_recent_context_usage = ( + {**capacity, **usage} if usage is not None else None + ) def remember_recent_context_usage(self, usage: dict[str, Any]) -> None: """Seed a source-validated transcript fallback after cold resume.""" total = usage.get("totalTokens") if isinstance(usage, dict) else None if (not isinstance(total, int) or isinstance(total, bool) - or total <= 0 or total > MAX_SAFE_WIRE_INTEGER): + or total < 0 or total > MAX_SAFE_WIRE_INTEGER): return self._last_recent_context_usage = dict(usage) @@ -839,6 +861,7 @@ def _discard_pending_turn_route(self) -> None: release.set() self._pending_turn_background_release = None self._pending_turn_origin_id = None + self._pending_compact = False async def query(self, prompt) -> None: """Send a request. `prompt` is a string, or an async iterable of user- @@ -892,6 +915,10 @@ async def query(self, prompt) -> None: # activated only when the pump sees this submitted turn. self._pending_turn_background_release = asyncio.Event() self._pending_turn_origin_id = self.next_turn_id + self._pending_compact = bool( + isinstance(prompt, str) + and prompt.split(maxsplit=1)[:1] == ["/compact"] + ) self.next_turn_id = None self._turn_active = True try: @@ -947,9 +974,13 @@ def set_auto_compact( self.auto_compact_mode = checked_mode self.auto_compact_threshold_tokens = checked_threshold - async def get_context_usage(self) -> dict: + async def get_context_usage(self, *, detail: str = "summary") -> dict: """Return the cc session's context window usage (matches CLI /context).""" - usage = await self._read_context_usage_control() + if detail not in {"summary", "full"}: + raise ValueError("invalid Claude context detail") + if self.context_probe_suppressed: + raise RuntimeError("Claude context refresh is awaiting normal traffic") + usage = await self._read_context_usage_control(detail=detail) self._record_context_usage(usage, update_model=True) return usage @@ -1264,6 +1295,7 @@ def _start_message_pump(self) -> None: self._turn_background_release = initial_release self._pending_turn_background_release = None self._pending_turn_origin_id = None + self._pending_compact = False client = self.client assert client is not None self._message_pump_task = asyncio.create_task( @@ -1308,6 +1340,29 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: else: owner = "background" self._message_route_owner = owner + elif ( + isinstance(message, SystemMessage) + and self._turn_active + and self._pending_compact + and origin_kind in {None, "human"} + and self._message_route_owner != "background" + and isinstance(message.data, dict) + and message.data.get("parent_tool_use_id") is None + and message.data.get("parentToolUseID") is None + and ( + (message.subtype == "compact_boundary" + and compact_metadata(message.data).get("trigger") == "manual") + or (message.subtype == "status" + and message.data.get("status") == "compacting") + ) + ): + # Native /compact can emit its boundary before replaying + # the submitted UserMessage. Only this pending command + # may claim it; ordinary prompts and autonomous turns + # keep their existing provenance-based routing. + owner = "managed" + self._activate_pending_turn_route() + self._message_route_owner = owner elif isinstance(message, ResultMessage): # Result.origin is the authoritative boundary in the # pinned SDK. A non-human result closes only the injected @@ -1346,6 +1401,7 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None: await self._turn_messages.put(message) if isinstance(message, ResultMessage): self._turn_active = False + self._pending_compact = False self._message_route_owner = None continue release = self._turn_background_release diff --git a/cc_remote/wrapper/stream.py b/cc_remote/wrapper/stream.py index d8b03ae9..afb130ff 100644 --- a/cc_remote/wrapper/stream.py +++ b/cc_remote/wrapper/stream.py @@ -39,6 +39,7 @@ TurnEnd, TurnResult, UserMsg, ) from cc_remote.wrapper.sanitize import bounded_text, bounded_tool_input +from cc_remote.wrapper.claude_compaction import compact_metadata from cc_remote.wrapper.claude_model_fallback import FALLBACK_TOOL, model_fallback_event from cc_remote.wrapper.turn_changes import native_claude_diff @@ -716,6 +717,7 @@ def __init__(self, tool_result_max: int, turn_id: str | None = None, # transcript UUID. Keep it separate from the browser's optimistic turn # id and from tool-result user envelopes. self._last_user_uuid: str | None = None + self._compaction_id: str | None = None def _remember_turn(self, item_id: str, parent_id: str | None = None) -> str | None: turn = (self.item_turns.get(item_id) @@ -1436,8 +1438,37 @@ def _feed_compaction(self, msg: SystemMessage) -> list[ProcessEvent]: if event is None: return [] event.turn_id = self.turn_id + if self._compaction_id is not None: + # Status UUIDs and persisted boundary UUIDs differ. Bind the live + # placeholder to its actual boundary so later history deduplicates. + event.input = {"compaction_started_id": self._compaction_id} + self._compaction_id = None return [event] + def _feed_compaction_status(self, msg: SystemMessage) -> list[ProcessEvent]: + data = msg.data if isinstance(msg.data, dict) else {} + if data.get("status") != "compacting": + if data.get("compact_error") and self._compaction_id is not None: + return self._end_unfinished_compaction("failed") + return [] + if self._compaction_id is not None: + return [] # Native heartbeats do not create new animations. + self._compaction_id = _wire_id( + data.get("uuid") or str(uuid.uuid4()), "compaction") + return [ProcessEvent( + item_id=self._compaction_id, kind="compaction", phase="start", + status="running", title="压缩上下文", turn_id=self.turn_id, + )] + + def _end_unfinished_compaction(self, status: str) -> list[ProcessEvent]: + if self._compaction_id is None: + return [] + item_id, self._compaction_id = self._compaction_id, None + return [ProcessEvent( + item_id=item_id, kind="compaction", phase="end", status=status, + title="压缩上下文", turn_id=self.turn_id, + )] + def _feed_hook(self, msg: HookEventMessage) -> list: data = msg.data if isinstance(msg.data, dict) else {} parent_raw = (data.get("tool_use_id") or data.get("toolUseID") @@ -1512,9 +1543,14 @@ def feed(self, msg) -> list: return self._feed_background_tasks_changed(msg) if msg.subtype == "compact_boundary": return self._feed_compaction(msg) + if msg.subtype == "status": + return self._feed_compaction_status(msg) return [] if isinstance(msg, ResultMessage): - events = [] + # A terminal without a boundary must not leave an eternal spinner + # or invent a successful compact (including interrupted commands). + events = self._end_unfinished_compaction( + "interrupted" if msg.is_error else "failed") if (not msg.is_error and not self._has_final_text and self._ambiguous_final_mid is not None): events.append(AssistantMsgEnd( @@ -2625,12 +2661,10 @@ def _compaction_event_from_row(row: dict[str, Any]) -> ProcessEvent | None: and _SAFE_WIRE_ID.fullmatch(uid) ): return None - metadata = row.get("compactMetadata") - if not isinstance(metadata, dict): - metadata = {} + metadata = compact_metadata(row) trigger = metadata.get("trigger") - pre_tokens = metadata.get("preTokens") - post_tokens = metadata.get("postTokens") + pre_tokens = metadata.get("pre_tokens") + post_tokens = metadata.get("post_tokens") summary_bits: list[str] = [] if trigger == "auto": summary_bits.append("自动压缩") @@ -2641,7 +2675,7 @@ def _compaction_event_from_row(row: dict[str, Any]) -> ProcessEvent | None: for value in (pre_tokens, post_tokens) ): summary_bits.append(f"{pre_tokens:,} → {post_tokens:,} tokens") - duration = metadata.get("durationMs") + duration = metadata.get("duration_ms") duration_ms = ( duration if isinstance(duration, int) and not isinstance(duration, bool) @@ -3003,6 +3037,13 @@ def close_turn( current_turn_id = message_uid background_followup = False elif isinstance(content, list): + if content and all( + isinstance(block, dict) and block.get("type") == "text" + and isinstance(block.get("text"), str) + and _is_meta_user_text(block["text"]) + for block in content + ): + continue if _is_interrupted_user_content(content): misplaced_alias = client_message_ids.get(message_uid) pending_interrupted_alias = ( @@ -3108,6 +3149,10 @@ def close_turn( elif role == "system": internal_event = (internal_user_events or {}).get(source_uid) if internal_event is not None: + # Manual compaction can finish minutes after an answer. Keep + # its process timestamp without retiming the settled response. + if settled_answer_seen or ambiguous_final_mid is not None: + advance_terminal_clock = False event = internal_event.model_copy(deep=True) event.turn_id = event.turn_id or current_turn_id timestamp = _ts(source_uid) @@ -3562,6 +3607,7 @@ def _is_meta_user_text(text: str) -> bool: or t.startswith("") or t.startswith("") or t.startswith("") + or t.startswith("") or t.startswith("") or t.startswith("") ) diff --git a/cc_remote/wrapper/work_context.py b/cc_remote/wrapper/work_context.py index 763213cd..9467189a 100644 --- a/cc_remote/wrapper/work_context.py +++ b/cc_remote/wrapper/work_context.py @@ -6,6 +6,7 @@ from typing import Any from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER +from cc_remote.wrapper.claude_compaction import compact_context_usage from cc_remote.wrapper.codex_sessions import codex_rollout_path from cc_remote.wrapper.stream import _bounded_jsonl_lines, transcript_path @@ -116,6 +117,15 @@ def recover_claude_context_usage( record = json.loads(raw) except (UnicodeError, ValueError): continue + if (isinstance(record, dict) + and record.get("type") == "system" + and record.get("subtype") == "compact_boundary" + and record.get("isSidechain") is not True + and record.get("parentToolUseID") is None + and record.get("parent_tool_use_id") is None): + # Never resurrect a pre-compact assistant count when the latest + # boundary has no post count (older CLI versions can omit it). + return compact_context_usage(record) if (not isinstance(record, dict) or record.get("type") != "assistant" or record.get("isSidechain") is True diff --git a/docs/configuration.md b/docs/configuration.md index d2fd039c..1db458fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,7 @@ ### Claude -Wrapper 使用日常 Claude Code,默认 `~/.local/bin/claude`,最低版本 `2.1.258`。 +Wrapper 使用日常 Claude Code,默认 `~/.local/bin/claude`,最低版本 `2.1.263`。 `CLAUDE_BIN` 留空仍使用该路径;显式覆盖必须是绝对路径。Agent SDK 固定为 `0.2.151`,不会使用 SDK 自带 CLI 替代你的日常安装。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 38efb796..a42802ab 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -14,7 +14,7 @@ ### Claude Wrapper uses daily Claude Code, normally `~/.local/bin/claude`, with a minimum -version of `2.1.258`. Empty `CLAUDE_BIN` still selects this path; an explicit +version of `2.1.263`. Empty `CLAUDE_BIN` still selects this path; an explicit override must be absolute. Agent SDK is pinned to `0.2.151`; its bundled CLI does not replace your daily installation. diff --git a/docs/timed-messages.md b/docs/timed-messages.md new file mode 100644 index 00000000..54171990 --- /dev/null +++ b/docs/timed-messages.md @@ -0,0 +1,53 @@ +# Timed messages + +Codex's ordinary queue messages do not include a schedule or a timer-origin +flag. cc-remote therefore uses explicit local task receipts, never prompt +wording, to render the **定时任务** tag and sidebar countdown. + +The scheduled-message helper uses the account's existing official app-server. +It queues into the specified native thread; it does not create a new session +or a Goal. The helper process is independent of the Wrapper process. + +From the cc-remote checkout or installed release directory, using its Python +environment: + +```bash +.venv/bin/python -m cc_remote.timed_tasks start \ + --codex-home /absolute/path/to/the/accounts/codex-home \ + --thread NATIVE_THREAD_UUID \ + --title '每分钟测试' --message '测试' --after 60 --every 60 --count 3 +``` + +Use the actual account home and native thread ID of the destination. A sidebar +routing ID such as `primary@UUID` is not a native thread ID. For an agent-created +reminder, obtain that identity from the current native session; do not guess the +most recently modified session. The destination must already exist, and its +shared daemon must be running. This helper does not launch or take over a daemon. + +The command returns a task ID and the first scheduled send time. A one-shot +reminder uses `--after 900 --count 1`. The finite count is required by the task +contract; do not turn an ordinary reminder into a persistent Goal. + +```bash +.venv/bin/python -m cc_remote.timed_tasks status TASK_UUID +.venv/bin/python -m cc_remote.timed_tasks cancel TASK_UUID +``` + +For a non-default Wrapper state location, put +`--state-dir /absolute/path/to/wrapper-state` before the subcommand. +The helper and Wrapper must read the same state directory. Prompts and account +paths stay in its private SQLite store; public task metadata contains only a +title, schedule, counters and message receipt identity. + +The sidebar remains idle while waiting. A task's outline stops after the final +message is accepted by the native queue, on cancellation/failure, or when its +worker heartbeat expires. Native queue acceptance is not model completion; +ordinary session state continues to describe the model's work. Cancelling +prevents subsequent sends and does not withdraw a message already submitted. +An uncertain submission is never automatically resent. After machine sleep, +missed intervals are not sent in a burst. + +Existing ad-hoc scripts that invoke `codex queue` directly have no reliable +next-send metadata. Their messages remain ordinary messages until explicit +receipts are provided; cc-remote does not infer schedules from text or label all +cross-session messages as timers. diff --git a/tests/test_claude_autocompact.py b/tests/test_claude_autocompact.py index 0594743f..8b67323a 100644 --- a/tests/test_claude_autocompact.py +++ b/tests/test_claude_autocompact.py @@ -90,7 +90,7 @@ def __init__( self, *, fail_first_reconnect: bool = False, - context_total: int | None = 0, + context_total: int | None = None, ): self.auto_compact_mode = "inherit" self.auto_compact_threshold_tokens = None @@ -584,7 +584,7 @@ async def run(): asyncio.run(run()) -def test_unknown_auto_target_compacts_nontrivial_context_before_reconnect(): +def test_unknown_auto_target_defers_compaction_to_native_cli(): async def run(): sdk = _CompactingAutoCompactSdk(context_total=200_000) machine, _transport, _ctx = _machine_with_sdk(sdk) @@ -594,7 +594,7 @@ async def run(): mode="auto", )) - assert sdk.queries == ["/compact"] + assert sdk.queries == [] assert sdk.reconnects[0][0:2] == ("auto", None) assert event.pending is False assert event.applied_mode == "auto" @@ -602,7 +602,7 @@ async def run(): asyncio.run(run()) -def test_unknown_legacy_window_compacts_once_before_adopting_default(): +def test_unknown_legacy_usage_does_not_force_compaction(): async def run(): sdk = _CompactingAutoCompactSdk(context_total=None) sdk.auto_compact_mode = "custom" @@ -615,7 +615,7 @@ async def run(): _ctx, reason="legacy default migration", ) - assert sdk.queries == ["/compact"] + assert sdk.queries == [] assert sdk.reconnects[0][0:2] == ("custom", 500_000) assert applied is True assert event.pending is False @@ -645,7 +645,7 @@ async def run(): ] assert sdk.reconnects[0][2]["preserve_model"] is True assert sdk.reconnects[1][2]["apply_pending_auto_compact"] is True - assert sdk.queries == ["/compact"] + assert sdk.queries == [] assert event.pending is False assert ctx.needs_reload is False @@ -834,7 +834,7 @@ async def run(): asyncio.run(run()) -def test_context_control_timeout_preserves_cache_but_returns_error(): +def test_context_control_timeout_publishes_last_valid_sample(): class ContextSdk(_AutoCompactSdk): control_plane_failed = False context_probe_suppressed = False @@ -875,14 +875,14 @@ async def run(): client_id="browser-one", )) - assert isinstance(report, Error) - assert report.code == "internal" + assert isinstance(report, ContextReport) + assert report.source == "recent_turn" + assert report.total_tokens == 88_259 + assert report.max_tokens == 500_000 + assert report.categories == [] assert report.request_id == "context-command" - assert report.to == "browser-one" assert transport.sent[-1] == report - assert not any( - isinstance(item, ContextReport) for item in transport.sent - ) + assert not any(isinstance(item, Error) for item in transport.sent) assert sdk.context_calls == 1 assert sdk.cached_context_usage()["totalTokens"] == 80_000 assert sdk.cached_recent_context_usage()["totalTokens"] == 88_259 @@ -890,7 +890,7 @@ async def run(): asyncio.run(run()) -def test_context_control_timeout_without_cache_reports_error(): +def test_context_control_timeout_without_cache_reports_unavailable(): class ContextSdk(_AutoCompactSdk): control_plane_failed = False context_probe_suppressed = False @@ -914,13 +914,11 @@ async def run(): client_id="browser-one", )) - assert isinstance(report, Error) - assert report.code == "internal" + assert isinstance(report, ContextReport) + assert report.available is False assert report.request_id == "context-command" - assert report.to == "browser-one" assert transport.sent[-1] == report - assert not any( - isinstance(item, ContextReport) for item in transport.sent) + assert not any(isinstance(item, Error) for item in transport.sent) asyncio.run(run()) @@ -949,17 +947,16 @@ async def run(): client_id="browser-one", )) - assert isinstance(report, Error) - assert report.code == "internal" + assert isinstance(report, ContextReport) + assert report.source == "recent_turn" + assert report.total_tokens == 123 assert report.request_id == "context-malformed" - assert report.to == "browser-one" - assert not any( - isinstance(item, ContextReport) for item in transport.sent) + assert not any(isinstance(item, Error) for item in transport.sent) asyncio.run(run()) -def test_poisoned_context_generation_reconnects_once_then_can_refresh(): +def test_poisoned_context_recovery_waits_for_normal_traffic_before_refresh(): class ContextSdk(_AutoCompactSdk): control_plane_failed = False context_probe_suppressed = False @@ -1003,9 +1000,10 @@ async def run(): client_id="browser-one", )) - assert isinstance(failed, Error) + assert isinstance(failed, ContextReport) + assert failed.available is False assert failed.request_id == "context-timeout" - assert "安全恢复" in failed.message + assert sdk.context_probe_suppressed is True assert len(sdk.reconnects) == 1 assert sdk.reconnects[0][2] == { "resume_id": SESSION_ID, @@ -1014,6 +1012,13 @@ async def run(): "fork": False, } + deferred = await machine._handle_get_context(GetContext( + sid=SESSION_ID, refresh=True, cmd_id="context-reopen")) + assert deferred.available is False + assert sdk.context_calls == 1 + assert len(sdk.reconnects) == 1 + # SdkHandle clears suppression on the next successful ResultMessage. + sdk.context_probe_suppressed = False report = await machine._handle_get_context(GetContext( sid=SESSION_ID, refresh=True, @@ -1027,7 +1032,7 @@ async def run(): assert report.max_tokens == 500_000 assert report.request_id == "context-retry" assert len(sdk.reconnects) == 1 - assert transport.sent[-2:] == [failed, report] + assert transport.sent[-3:] == [failed, deferred, report] asyncio.run(run()) diff --git a/tests/test_claude_compaction_flow.py b/tests/test_claude_compaction_flow.py new file mode 100644 index 00000000..df33f07c --- /dev/null +++ b/tests/test_claude_compaction_flow.py @@ -0,0 +1,182 @@ +"""Exercise native compact ordering through the real SDK pump and projection.""" +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +import pytest +from claude_agent_sdk.types import ResultMessage, SystemMessage + +from cc_remote.protocol import GetContext, ProcessEvent +from cc_remote.wrapper.claude_compaction import compact_metadata +from cc_remote.wrapper.sdk import SdkHandle +from cc_remote.wrapper.stream import StreamTranslator +from cc_remote.wrapper.work_context import recover_claude_context_usage +from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk + + +def boundary(): + return { + "type": "system", "subtype": "compact_boundary", "uuid": "native-boundary", + "compact_metadata": { + "trigger": "manual", "pre_tokens": 600_000, + "post_tokens": 8_000, "duration_ms": 20_000, + }, + } + + +def result(): + return { + "type": "result", "subtype": "success", "duration_ms": 20_000, + "duration_api_ms": 19_000, "is_error": False, "num_turns": 1, + "session_id": SESSION_ID, + } + + +def status(): + return {"type": "system", "subtype": "status", "status": "compacting", + "uuid": "compact-start"} + + +class CompactClient: + def __init__(self, rows): + self._query = self + self.queue = asyncio.Queue() + self.rows = rows + self.prompts = [] + + async def query(self, prompt): + self.prompts.append(prompt) + for row in self.rows: + await self.queue.put(row) + + async def receive_messages(self): + while True: + yield await self.queue.get() + + +@pytest.mark.parametrize("replay_user", [False, True]) +@pytest.mark.parametrize("post_tokens", [8_000, 0]) +def test_native_compact_boundary_before_user_replay_reaches_control(replay_user, post_tokens): + async def run(): + compact = boundary() + compact["compact_metadata"]["post_tokens"] = post_tokens + rows = [status(), status(), compact] + if replay_user: + rows.append({"type": "user", "uuid": "native-user", + "message": {"role": "user", "content": "/compact"}}) + rows.append(result()) + handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4)) + handle.client = CompactClient(rows) + handle._record_context_usage({ + "totalTokens": 600_000, "maxTokens": 800_000, + "rawMaxTokens": 1_000_000, "autoCompactThreshold": 800_000, + }) + background = [] + + async def on_background(message, _turn): + background.append(message) + + handle.background_message_callback = on_background + handle._start_message_pump() + machine, transport, ctx = _machine_with_sdk(handle) + try: + event = await asyncio.wait_for(machine._compact_managed_claude_context( + ctx, reason="test native order"), timeout=1) + assert event.item_id == "native-boundary" + assert event.summary == f"手动压缩 · 600,000 → {post_tokens:,} tokens" + assert event.duration_ms == 20_000 + assert event.input == {"compaction_started_id": "compact-start"} + assert handle.client.prompts == ["/compact"] + assert background == [] + processes = [e for e in transport.sent if isinstance(e, ProcessEvent)] + assert [e.phase for e in processes] == ["start", "end"] + assert ctx.claude_compaction_revision == 1 + report = await machine._handle_get_context(GetContext(sid=SESSION_ID)) + assert report.available is not False + assert report.total_tokens == post_tokens + assert report.max_tokens == 800_000 + assert report.auto_compact_threshold_tokens == 800_000 + assert report.categories == [] + finally: + await handle._stop_message_pump() + + asyncio.run(run()) + + +def test_malformed_context_read_preserves_count_and_capacity(): + handle = SdkHandle(SimpleNamespace()) + valid = {"totalTokens": 8_000, "maxTokens": 800_000, + "rawMaxTokens": 1_000_000, "autoCompactThreshold": 800_000} + handle._record_context_usage(valid) + handle._record_context_usage({"model": "claude-sonnet-4-6"}) + assert handle.cached_context_usage() == valid + assert handle.raw_context_max_tokens == 1_000_000 + assert handle.effective_auto_compact_threshold_tokens == 800_000 + + +def test_regular_prompt_does_not_claim_an_earlier_compact_boundary(): + async def run(): + rows = [status(), boundary(), + {"type": "user", "uuid": "new-user", "origin": {"kind": "human"}, + "message": {"role": "user", "content": "continue"}}, result()] + handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4)) + handle.client = CompactClient(rows) + background = [] + + async def on_background(message, _turn): + background.append(message) + + handle.background_message_callback = on_background + handle._start_message_pump() + try: + await handle.query("continue") + messages = [message async for message in handle.receive_response()] + handle.release_background_messages() + await asyncio.wait_for(handle._background_callbacks_drained.wait(), 1) + assert not any(isinstance(m, SystemMessage) for m in messages) + assert [m.subtype for m in background] == ["status", "compact_boundary"] + assert isinstance(messages[-1], ResultMessage) + finally: + await handle._stop_message_pump() + + asyncio.run(run()) + + +@pytest.mark.parametrize("is_error", [False, True]) +def test_terminal_without_boundary_stops_animation_without_success(is_error): + translator = StreamTranslator(1024, turn_id="turn") + translator.feed(SystemMessage(subtype="status", data=status())) + terminal = result() + terminal.pop("type") + terminal["is_error"] = is_error + events = translator.feed(ResultMessage(**terminal)) + process = next(e for e in events if isinstance(e, ProcessEvent)) + assert process.item_id == "compact-start" + assert process.phase == "end" + assert process.status == ("interrupted" if is_error else "failed") + assert process.summary is None + + +@pytest.mark.parametrize("post", [8_000, 0, None]) +def test_cold_context_read_stops_at_latest_compact_boundary(tmp_path, post): + path = tmp_path / "session.jsonl" + end = boundary() + end["compact_metadata"]["post_tokens"] = post + old = {"type": "assistant", "message": {"usage": {"input_tokens": 600_000}}} + path.write_text("\n".join(map(json.dumps, [old, end]))) + expected = {"totalTokens": post} if post is not None else None + assert recover_claude_context_usage(SESSION_ID, path=str(path)) == expected + with path.open("a") as stream: + stream.write("\n" + json.dumps({"type": "assistant", "message": { + "usage": {"input_tokens": 8_000, "output_tokens": 500}}})) + assert recover_claude_context_usage(SESSION_ID, path=str(path)) == { + "totalTokens": 8_500} + + +@pytest.mark.parametrize("value", [True, -1, 2**54, [], "8000"]) +def test_compact_metadata_does_not_publish_invalid_counters(value): + assert compact_metadata({"compact_metadata": { + "trigger": [], "pre_tokens": value, "post_tokens": value, + "duration_ms": value}}) == {} diff --git a/tests/test_claude_compaction_history.py b/tests/test_claude_compaction_history.py new file mode 100644 index 00000000..ae1ea7ec --- /dev/null +++ b/tests/test_claude_compaction_history.py @@ -0,0 +1,148 @@ +"""Native manual compaction must not manufacture a human conversation turn.""" +import json +import sqlite3 + +import pytest + +from cc_remote.protocol import ProcessEvent, TurnEnd, UserMsg +from cc_remote.wrapper.history_store import HistoryIndexStore, HistorySourceFingerprint +from tests.test_history_store import _page +from cc_remote.wrapper.stream import ( + transcript_compact_history_page, + transcript_compact_snapshot, + translate_history, +) + +SID = "11111111-1111-4111-8111-111111111111" + + +@pytest.mark.parametrize("blocks", [False, True]) +def test_manual_compact_replay_preserves_answer_and_pagination(tmp_path, blocks): + def row(uid, role, content, timestamp, parent=None, **extra): + return {"uuid": uid, "type": role, "parentUuid": parent, + "timestamp": timestamp, "message": {"role": role, "content": content}, + **extra} + + caveat = "Internal command disclaimer" + if blocks: + caveat = [{"type": "text", "text": caveat}] + records = [ + row("human", "user", "inspect the code", "2026-09-15T02:38:00Z"), + row("answer", "assistant", [{"type": "text", "text": "Inspection complete."}], + "2026-09-15T02:38:05Z", "human"), + {"uuid": "boundary", "type": "system", "subtype": "compact_boundary", + "parentUuid": None, "logicalParentUuid": "answer", + "timestamp": "2026-09-15T02:46:05Z", + "compactMetadata": {"trigger": "manual", "preTokens": 600_000}}, + row("summary", "user", "This session is being continued from a previous conversation.", + "2026-09-15T02:46:05Z", "boundary", isCompactSummary=True), + row("caveat", "user", caveat, "2026-09-15T02:43:34Z", "summary", isMeta=True), + row("command", "user", "/compact", + "2026-09-15T02:43:34Z", "caveat"), + row("stdout", "user", "Compacted", + "2026-09-15T02:46:07Z", "command"), + ] + source = tmp_path / f"{SID}.jsonl" + source.write_text("".join(json.dumps(r) + "\n" for r in records)) + store = HistoryIndexStore(tmp_path / "index") + for iteration in range(3): # Initial graph, unchanged cache, and v38 upgrade. + messages, timestamps, internal = transcript_compact_snapshot( + SID, path=str(source), index_store=store) + events = translate_history(messages, 4096, timestamps, internal) + assert [e.prompt for e in events if isinstance(e, UserMsg)] == ["inspect the code"] + ends = [e for e in events if isinstance(e, TurnEnd)] + assert len(ends) == 1 + assert ends[0].ts == timestamps["answer"] + assert ends[0].result.duration_ms == 5_000 + compact = [e for e in events if isinstance(e, ProcessEvent) and e.kind == "compaction"] + assert len(compact) == 1 + assert compact[0].ts == timestamps["boundary"] + page = transcript_compact_history_page(SID, path=str(source), index_store=store, limit=1) + assert page is not None + assert not page.has_more + assert page.oldest_cursor == "human" + if iteration == 1: + fingerprint = HistorySourceFingerprint.capture(source) + for engine in ("claude", "codex"): + store.put_page(SID, engine, fingerprint, before=None, limit=4, page=_page(engine)) + store.put_image_asset(SID, engine, fingerprint, engine, "image", + "thumbnail", "image/png", 1, 1, b"image") + with sqlite3.connect(store.path) as db: + db.execute("UPDATE claude_compact_records SET visible_user=1 WHERE uuid='caveat'") + db.execute("PRAGMA user_version=38") + store = HistoryIndexStore(tmp_path / "index") + assert store.get_page(SID, "claude", fingerprint, before=None, limit=4) is None + assert store.get_page(SID, "codex", fingerprint, before=None, limit=4) == _page("codex") + with sqlite3.connect(store.path) as db: + assert db.execute("SELECT count(*) FROM claude_compact_records").fetchone()[0] == 0 + assert db.execute("SELECT count(*) FROM history_image_assets").fetchone()[0] == 2 + + +def test_compact_controls_survive_reload_in_the_same_account(tmp_path): + import asyncio + from copy import copy + + from cc_remote.wrapper.claude_controls import ClaudeControlStore + from tests.test_claude_autocompact import _AutoCompactSdk, _machine_with_sdk + + async def run(): + sdk = _AutoCompactSdk() + sdk.set_auto_compact("custom", 500_000) + machine, _, ctx = _machine_with_sdk(sdk) + machine._claude_controls = ClaudeControlStore(tmp_path) + machine.sessions.clear() + ctx.key = f"primary@{ctx.session_id}" + machine.sessions[ctx.key] = ctx + sibling = copy(ctx) + sibling.key = f"stack@{ctx.session_id}" + sibling.sdk = _AutoCompactSdk() + sibling.sdk.set_auto_compact("custom", 700_000) + machine.sessions[sibling.key] = sibling + await machine._persist_claude_session_controls(ctx) + await machine._persist_claude_session_controls(sibling) + machine._claude_controls = ClaudeControlStore(tmp_path) + saved = await machine._load_claude_session_controls(ctx.key) + assert saved.auto_compact_threshold_tokens == 500_000 + assert saved.applied_auto_compact_mode == "inherit" + assert (await machine._load_claude_session_controls( + sibling.key)).auto_compact_threshold_tokens == 700_000 + assert machine._claude_controls.get(ctx.session_id).auto_compact_mode == "inherit" + + asyncio.run(run()) + + +def test_manual_compact_resyncs_profile_scoped_watch(tmp_path): + import asyncio + from types import SimpleNamespace + + from cc_remote.wrapper.sdk import SdkHandle + from tests.test_claude_autocompact import _machine_with_sdk + from tests.test_claude_compaction_flow import CompactClient, boundary, result, status + + async def run(): + path = tmp_path / "native.jsonl" + path.write_text("before") + handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4)) + handle.client = CompactClient([status(), boundary(), result()]) + original = handle.client.query + + async def compact(prompt): + path.write_text("before plus native compact records") + await original(prompt) + + handle.client.query = compact + handle._start_message_pump() + machine, _, ctx = _machine_with_sdk(handle) + machine.sessions.clear() + ctx.key = f"primary@{ctx.session_id}" + machine.sessions[ctx.key] = ctx + watch = {"path": str(path), "size": len("before")} + machine._watch[ctx.key] = watch + try: + await machine._compact_managed_claude_context(ctx, reason="test") + assert watch["size"] == path.stat().st_size + assert not ctx.claude_write_active + finally: + await handle._stop_message_pump() + + asyncio.run(run()) diff --git a/tests/test_claude_permission_state.py b/tests/test_claude_permission_state.py index 2b73a157..2147175e 100644 --- a/tests/test_claude_permission_state.py +++ b/tests/test_claude_permission_state.py @@ -50,7 +50,7 @@ async def get_context_usage(self): return {"model": self.options.model or "claude-mythos-5"} async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} assert timeout == 5.0 return await self.get_context_usage() @@ -404,9 +404,9 @@ class BlockingContext(_FakeClaudeClient): release_context: asyncio.Event async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} if self.block_context: - assert timeout == 15.0 + assert timeout == 60.0 self.context_started.set() await self.release_context.wait() else: @@ -668,7 +668,7 @@ class ProbeTimeout(_FakeClaudeClient): probes = 0 async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} assert timeout == 5.0 type(self).probes += 1 raise Exception("Control request timeout: get_context_usage") @@ -703,7 +703,7 @@ class ProbeTimeout(_FakeClaudeClient): probes = 0 async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} assert timeout == 5.0 type(self).probes += 1 raise Exception("Control request timeout: get_context_usage") @@ -721,7 +721,7 @@ async def go(): assert ProbeTimeout.created[0].disconnected is True assert handle.client is ProbeTimeout.created[1] assert handle.control_plane_failed is False - assert handle.context_probe_suppressed is False + assert handle.context_probe_suppressed is True await handle.query("fresh child is ready") assert ProbeTimeout.created[1].prompt == "fresh child is ready" @@ -735,11 +735,11 @@ class ContextTimeout(_FakeClaudeClient): fail_context = False async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} if self.fail_context: - assert timeout == 15.0 + assert timeout == 60.0 raise Exception("Control request timeout: get_context_usage") - assert timeout in {5.0, 15.0} + assert timeout in {5.0, 60.0} return {"model": "claude-mythos-5", "totalTokens": 123} async def go(): @@ -807,9 +807,9 @@ class ContextTimeout(_FakeClaudeClient): fail_context = False async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} if self.fail_context: - assert timeout == 15.0 + assert timeout == 60.0 raise Exception("Control request timeout: get_context_usage") assert timeout == 5.0 return { @@ -861,9 +861,9 @@ class BlockingContext(_FakeClaudeClient): release_context: asyncio.Event async def _send_control_request(self, request, timeout): - assert request == {"subtype": "get_context_usage"} + assert request == {"subtype": "get_context_usage", "detail": "summary"} if self.block_context: - assert timeout == 15.0 + assert timeout == 60.0 self.context_started.set() await self.release_context.wait() else: diff --git a/tests/test_codex_profiles.py b/tests/test_codex_profiles.py index 181c5884..ab1e5769 100644 --- a/tests/test_codex_profiles.py +++ b/tests/test_codex_profiles.py @@ -3574,3 +3574,23 @@ async def broadcast(*_args, **_kwargs): assert result["last_run_status"] == "failed" assert result["last_run_attempt"] == 1 assert "账号不可用" in result["last_error"] + + +def test_native_start_hint_without_catalog_does_not_publish_empty_session(tmp_path, monkeypatch): + async def run(): + machine, _ = _machine(tmp_path) + primary = _context("primary@current", "current", "primary") + machine.sessions[primary.key] = primary + + async def no_catalog(_limit, *, codex_home=None): + return [] + + monkeypatch.setattr(machine_module, "list_codex_sessions", no_catalog) + monkeypatch.setattr(machine_module, "codex_exact_catalog_rows", lambda *a, **kw: []) + machine._on_codex_thread_started_hint(primary, "unmaterialized-helper") + await asyncio.gather(*tuple(machine._codex_catalog_hint_tasks)) + rows = await machine._read_all_codex_profile_sessions() + assert all(row["native_session_id"] != "unmaterialized-helper" for row in rows) + assert any(row["native_session_id"] == "current" for row in rows) + + asyncio.run(run()) diff --git a/tests/test_engine_versions.py b/tests/test_engine_versions.py index 09281ea1..b668abbc 100644 --- a/tests/test_engine_versions.py +++ b/tests/test_engine_versions.py @@ -64,7 +64,7 @@ def test_claude_runtime_rejects_failed_version_probe(monkeypatch, tmp_path): claude_runtime.subprocess, "run", lambda *args, **kwargs: SimpleNamespace( - stdout="2.1.258 (Claude Code)\n", stderr="", returncode=1, + stdout="2.1.263 (Claude Code)\n", stderr="", returncode=1, ), ) @@ -72,12 +72,12 @@ def test_claude_runtime_rejects_failed_version_probe(monkeypatch, tmp_path): claude_runtime.probe_claude_cli_version(str(cli)) -@pytest.mark.parametrize("version", ["2.1.258", "2.1.258+build.1", "2.2.0", "3.0.0"]) +@pytest.mark.parametrize("version", ["2.1.263", "2.1.263+build.1", "2.2.0", "3.0.0"]) def test_claude_runtime_accepts_supported_cli_versions(version): assert claude_runtime.validate_cli_version(version) == version -@pytest.mark.parametrize("version", ["2.1.257", "2.1.258-beta.1"]) +@pytest.mark.parametrize("version", ["2.1.257", "2.1.263-beta.1"]) def test_claude_runtime_rejects_unsupported_cli_versions(version): with pytest.raises(RuntimeError, match="run `claude update`"): claude_runtime.validate_cli_version(version) @@ -95,7 +95,7 @@ def test_claude_runtime_inspection_enforces_cli_minimum(monkeypatch, tmp_path): claude_runtime, "probe_claude_cli_version", lambda _path: "2.1.257", ) - with pytest.raises(RuntimeError, match="older than required 2.1.258"): + with pytest.raises(RuntimeError, match="older than required 2.1.263"): claude_runtime.inspect_claude_runtime(str(cli)) diff --git a/tests/test_history_store.py b/tests/test_history_store.py index 7bacf24e..b2a38fac 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -461,14 +461,14 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" ).fetchone()[0] == 0 assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" - ).fetchone()[0] == (1 if table == "history_turn_details" else 0) + ).fetchone()[0] == 0 assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2 assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None assert migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("codex")) @@ -477,7 +477,7 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm @pytest.mark.parametrize("old_version", [34, 35, 36, 37]) -def test_codex_narrative_migration_preserves_other_engines_and_assets(tmp_path, old_version): +def test_narrative_migration_preserves_assets(tmp_path, old_version): path = tmp_path / "rollout.jsonl" path.write_text("{}\n") source = HistorySourceFingerprint.capture(path) @@ -489,10 +489,10 @@ def test_codex_narrative_migration_preserves_other_engines_and_assets(tmp_path, connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") assert migrated.get_page("codex", "codex", source, before=None, limit=4) is None - assert migrated.get_page("claude", "claude", source, before=None, limit=4) is not None + assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None with sqlite3.connect(store.path) as connection: assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='codex'").fetchone()[0] == 0 - assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='claude'").fetchone()[0] == 1 + assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='claude'").fetchone()[0] == 0 assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2 migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("rebuilt")) reopened = HistoryIndexStore(tmp_path / "state") @@ -518,7 +518,7 @@ def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path): assert migrated.get_page( "session-1", "claude", source, before=None, limit=4) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 assert connection.execute( "SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0 @@ -546,7 +546,7 @@ def test_v20_migration_rebuilds_codex_and_claude_identity_projections(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -607,7 +607,7 @@ def test_v21_migration_rebuilds_claude_alias_and_codex_process_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -664,7 +664,7 @@ def test_v22_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -726,7 +726,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -757,7 +757,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs( @pytest.mark.parametrize("old_version", [25, 26, 27, 28, 29, 30, 31]) -def test_async_question_migration_preserves_assets_and_unaffected_claude_details( +def test_async_question_migration_preserves_assets( tmp_path, old_version, ): source_path = tmp_path / "transcript.jsonl" @@ -797,11 +797,11 @@ def test_async_question_migration_preserves_assets_and_unaffected_claude_details migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" - ).fetchone()[0] == int(old_version == 31 and table == "history_turn_details") + ).fetchone()[0] == 0 assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" ).fetchone()[0] == 0 @@ -906,7 +906,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ( "history_pages", "history_turn_details", @@ -953,7 +953,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1004,7 +1004,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index( "claude-session", "claude", source, before=None, limit=4, ) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -1050,7 +1050,7 @@ def test_recent_migration_invalidates_changed_projection_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1063,7 +1063,7 @@ def test_recent_migration_invalidates_changed_projection_rows( 1 if table == "history_image_assets" else 0) assert connection.execute( "SELECT COUNT(*) FROM claude_compact_sources" - ).fetchone()[0] == 1 + ).fetchone()[0] == 0 @pytest.mark.parametrize("old_version", [15, 16]) @@ -1092,7 +1092,7 @@ def test_owner_and_interrupt_alias_migration_invalidates_both_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -1156,7 +1156,7 @@ def test_recent_summary_migration_rebuilds_pages_but_preserves_source_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 38 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 assert connection.execute( "SELECT COUNT(*) FROM history_pages" ).fetchone()[0] == 0 diff --git a/tests/test_timed_tasks.py b/tests/test_timed_tasks.py new file mode 100644 index 00000000..985bd514 --- /dev/null +++ b/tests/test_timed_tasks.py @@ -0,0 +1,187 @@ +import asyncio +import json +import sqlite3 +from uuid import uuid4 + +import pytest + +from cc_remote import timed_tasks as timed +from cc_remote.protocol import ConversationTurn, SessionInfo, TimedMessage, UserMsg, deserialize, serialize + + +def task(tmp_path, monkeypatch, *, count=3): + monkeypatch.setattr(timed.time, "time", lambda: 1000) + home = tmp_path / "codex" + home.mkdir() + store = timed.TimedTaskStore(tmp_path / "state") + sid = str(uuid4()) + task_id = store.create(str(home), sid, "每分钟测试", "测试", 60, 60, count) + return store, str(home), sid, task_id + + +def test_receipts_are_account_session_and_message_scoped(tmp_path, monkeypatch): + store, home, sid, task_id = task(tmp_path, monkeypatch) + delivery = store.begin_delivery(task_id) + store.accepted(task_id, delivery) + source = store.source(home, sid, [delivery], bind="native-message") + assert source == {"task_id": task_id, "title": "每分钟测试", "scheduled_at": 1060} + assert store.source(home, sid, ["native-message"]) == source + assert store.source(home, str(uuid4()), [delivery]) is None + assert store.source(home + "-other", sid, [delivery]) is None + # Identical text and an unrelated user message never inherit a timer badge. + assert store.source(home, sid, ["测试", "manual-message"]) is None + event = UserMsg(msg_id="native-message", prompt="测试", timed_task=TimedMessage(**source)) + assert deserialize(serialize(event)).timed_task == event.timed_task + assert ConversationTurn(id="native-message", timedTask=source).timedTask.task_id == task_id + assert SessionInfo(session_id=sid, timed_tasks=store.public_tasks(home, sid)).state is None + + +def test_completion_cancel_and_expired_lease_remove_glow_but_keep_receipt(tmp_path, monkeypatch): + store, home, sid, task_id = task(tmp_path, monkeypatch, count=1) + assert store.public_tasks(home, sid)[0]["next_message_at"] == 1060 + assert store.active(now=1091) == {} + delivery = store.begin_delivery(task_id) + store.accepted(task_id, delivery) + store.accepted(task_id, delivery) # Duplicate acknowledgment is harmless. + assert store.get(task_id)["sent"] == 1 + assert store.get(task_id)["state"] == "completed" + assert store.active() == {} + assert store.source(home, sid, [delivery]) + second = store.create(home, sid, "Later", "hi", 60, 60, 1) + store.finish(second, "cancelled") + assert store.public_tasks(home, sid) == [] + with pytest.raises(ValueError): + store.begin_delivery(second) + + +def test_ambiguous_delivery_cannot_be_retried_and_wakeup_does_not_burst(tmp_path, monkeypatch): + store, home, sid, task_id = task(tmp_path, monkeypatch) + delivery = store.begin_delivery(task_id) + with pytest.raises(sqlite3.IntegrityError): + store.begin_delivery(task_id) + monkeypatch.setattr(timed.time, "time", lambda: 1600) + store.accepted(task_id, delivery) + assert store.get(task_id)["next_at"] == 1660 + + +def test_read_missing_store_has_no_side_effects_and_symlinks_are_rejected(tmp_path): + store = timed.TimedTaskStore(tmp_path / "missing") + assert store.active() == {} + assert store.source("home", "sid", ["id"], bind="native") is None + assert not store.path.parent.exists() + store.path.parent.mkdir() + store.path.symlink_to(tmp_path / "elsewhere") + with pytest.raises(ValueError): + store.active() + assert not (tmp_path / "elsewhere").exists() + + +def test_worker_marks_unknown_once_without_resending(tmp_path, monkeypatch): + store, home, sid, task_id = task(tmp_path, monkeypatch) + monkeypatch.setattr(timed.time, "time", lambda: 1061) + calls = [] + async def rejected(task, delivery, on_accepted): + calls.append(delivery) + raise ConnectionResetError() + monkeypatch.setattr(timed, "deliver", rejected) + asyncio.run(timed.run_task(store, task_id)) + asyncio.run(timed.run_task(store, task_id)) + assert len(calls) == 1 + assert store.get(task_id)["state"] == "unknown" + assert store.active() == {} + + +def test_queue_uses_existing_thread_exact_client_id_and_accepts_before_start(tmp_path, monkeypatch): + import os + import stat + from types import SimpleNamespace + store, home, sid, task_id = task(tmp_path, monkeypatch, count=1) + socket_path = tmp_path / "codex" / "app-server-control" / "app-server-control.sock" + original_lstat = timed.Path.lstat + monkeypatch.setattr(timed.Path, "lstat", lambda path, *args, **kwargs: + SimpleNamespace(st_mode=stat.S_IFSOCK, st_uid=os.getuid()) + if path == socket_path else original_lstat(path, *args, **kwargs)) + delivery = store.begin_delivery(task_id) + methods = [] + class WS: + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + async def send(self, raw): + self.message = json.loads(raw) + methods.append(self.message["method"]) + async def recv(self): + msg = self.message + if msg["method"] == "thread/queue/start": + assert store.get(task_id)["state"] == "completed" + raise ConnectionResetError() + if msg["method"] == "thread/queue/add": + assert msg["params"]["threadId"] == sid + assert msg["params"]["clientUserMessageId"] == delivery + result = {"queuedSubmission": {"id": "queue-id", "clientUserMessageId": delivery}} + else: + result = {"thread": {"status": {"type": "idle"}}} + return json.dumps({"id": msg["id"], "result": result}) + monkeypatch.setattr(timed, "unix_connect", lambda *a, **kw: WS()) + asyncio.run(timed.deliver(store.get(task_id), delivery, lambda: store.accepted(task_id, delivery))) + assert methods == ["initialize", "initialized", "thread/read", "thread/queue/add", + "thread/read", "thread/queue/start"] + assert store.get(task_id)["sent"] == 1 + + +def test_history_overlay_uses_receipts_for_summary_and_full_pages(tmp_path, monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + from cc_remote.protocol import History + from cc_remote.wrapper.machine import WrapperMachine + store, home, sid, task_id = task(tmp_path, monkeypatch) + delivery = store.begin_delivery(task_id) + store.accepted(task_id, delivery) + machine = object.__new__(WrapperMachine) + machine._timed_tasks = store + machine._ctx_by_sid = lambda sid: None + machine._codex_target = lambda sid: (SimpleNamespace(home=home), sid) + machine._build_history_source = AsyncMock(return_value=History( + session_id=sid, revision="r", detail="summary", + turns=[ConversationTurn(id="native", clientMsgId=delivery, prompt="测试"), + ConversationTurn(id="manual", prompt="测试")], + events=[{"type": "user_msg", "msg_id": "native", "client_msg_id": delivery, "prompt": "测试"}], + )) + history = asyncio.run(machine._build_history(sid, detail="summary")) + assert history.turns[0].timedTask.task_id == task_id + assert history.turns[1].timedTask is None + assert history.events[0]["timed_task"]["task_id"] == task_id + assert machine._build_history_source.await_count == 1 + + +def test_official_history_request_preserves_timed_message_receipts(tmp_path, monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + from cc_remote.protocol import History + from cc_remote.wrapper.machine import WrapperMachine + + store, home, sid, task_id = task(tmp_path, monkeypatch) + delivery = store.begin_delivery(task_id) + store.accepted(task_id, delivery) + machine = object.__new__(WrapperMachine) + machine._timed_tasks = store + machine._ctx_by_sid = lambda sid: None + machine._codex_target = lambda sid: (SimpleNamespace(home=home), sid) + machine._watch_session = lambda sid: None + machine._watch = {sid: {"engine": "codex"}} + machine._history_revision = lambda sid: "r" + machine._history_continuity_revisions = {} + machine._codex_rollout_history_active = lambda sid: False + machine._codex_terminal_snapshot = AsyncMock(return_value=[]) + machine._build_official_codex_history = AsyncMock(return_value=History( + session_id=sid, revision="r", detail="summary", + turns=[ConversationTurn(id="native", clientMsgId=delivery, prompt="测试"), + ConversationTurn(id="manual", prompt="测试")], + )) + machine._build_history_source = AsyncMock(side_effect=AssertionError("Must use official history")) + history = asyncio.run(machine._build_requested_history( + sid, before=None, limit=4, cwd=None, detail="summary")) + assert history.turns[0].timedTask is not None + assert history.turns[0].timedTask.task_id == task_id + assert history.turns[1].timedTask is None + machine._build_official_codex_history.assert_awaited_once() + machine._build_history_source.assert_not_awaited() diff --git a/web/package.json b/web/package.json index 796a273e..e3429223 100644 --- a/web/package.json +++ b/web/package.json @@ -19,7 +19,7 @@ "test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit", "test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js", "test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", - "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", + "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/web/scripts/check-bundle-budget.mjs b/web/scripts/check-bundle-budget.mjs index d895944a..fbdf438e 100644 --- a/web/scripts/check-bundle-budget.mjs +++ b/web/scripts/check-bundle-budget.mjs @@ -29,8 +29,10 @@ const DIST = resolve(import.meta.dirname, "../dist"); // The card has no new dependencies; gzip and initial request caps stay unchanged. // Quota failure copy and validated native retry dates add <1 KiB at startup. // Keep entry, compressed-size, and request-count caps unchanged. +// Native compaction animation and exact boundary replacement add <1 KiB of +// startup JS. Keep entry, compressed-size and request-count limits unchanged. const MAX_ENTRY_BYTES = 537 * 1024; -const MAX_INITIAL_BYTES = 937 * 1024; +const MAX_INITIAL_BYTES = 938 * 1024; const MAX_INITIAL_GZIP_BYTES = 280 * 1024; const MAX_INITIAL_JS_FILES = 4; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index ffece69f..a905e1aa 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -22,6 +22,7 @@ import type { import type { Space } from "../protocol"; import type { LoadTurnFilePage } from "../turn-file-pages"; import { MessageBlock } from "./MessageBlock"; +import { TimedMessageTag } from "./TimedMessageTag"; import { Icon, ClaudeMark, ClaudeWorking, ClaudeSpark } from "../icons"; import { canForkTurn } from "../session-worktree"; import { @@ -2838,7 +2839,9 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading }}> {(t.prompt || (t.images && t.images.length) || (t.imageRefs && t.imageRefs.length) || (t.files && t.files.length)) && (
- {t.prompt &&
{supplemental.replies.has(t.id) + {t.prompt &&
+ {t.timedTask && } + {supplemental.replies.has(t.id) ?
查看问题 diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 3342dbc5..69ce5da1 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -737,7 +737,8 @@ export function Composer(p: Props) { // A background read may temporarily return billing usage or no estimate. // Keep the last native reading until another arrives. Runtime invalidation // already clears both reports when the session's model or capacity changes. - const exactContextReport = currentContextReport?.source !== "recent_turn" && currentContextReport + const exactContextReport = currentContextReport + && (p.engine !== "codex" || currentContextReport.source !== "recent_turn") ? currentContextReport : retainedContextReport ?? currentContextReport; const codexEstimate = p.engine !== "codex" || exactContextReport?.source === "native_estimate"; @@ -1129,6 +1130,7 @@ export function Composer(p: Props) { { setCtxOpen(false); setAutoCompactOpen(true); } : undefined} /> diff --git a/web/src/components/ContextPopover.tsx b/web/src/components/ContextPopover.tsx index 10e73809..da1c5062 100644 --- a/web/src/components/ContextPopover.tsx +++ b/web/src/components/ContextPopover.tsx @@ -1,4 +1,4 @@ -import type { CodexContext, ContextReport } from "../protocol"; +import type { AutoCompact, CodexContext, ContextReport } from "../protocol"; import { workContextMetrics } from "../work-context"; export const CODEX_CONTEXT_USAGE_NOTE = "进度按 Codex 原生上下文估算显示;后台刷新期间保留最近有效读数。"; @@ -9,6 +9,7 @@ interface Props { work?: boolean; onAutoCompact?: () => void; codexContext?: CodexContext | null; + autoCompact?: AutoCompact | null; codex?: boolean; } @@ -84,6 +85,17 @@ export default function ContextPopover(p: Props) { {p.report.model &&
{p.report.model}
} ) :
上下文窗口
} + {p.autoCompact && <> +
生效压缩阈值 + {p.autoCompact.applied_mode === "custom" + ? p.autoCompact.applied_threshold_tokens?.toLocaleString() ?? "待确认" + : p.report?.auto_compact_threshold_tokens?.toLocaleString() ?? "Claude 默认值"} +
+ {p.autoCompact.pending &&
+ 已保存 {p.autoCompact.mode === "custom" + ? p.autoCompact.threshold_tokens?.toLocaleString() : "默认值"},等待生效 +
} + } {p.codexContext && <>
生效压缩阈值 {(p.report?.source === "native_estimate" diff --git a/web/src/components/ProcessTimeline.tsx b/web/src/components/ProcessTimeline.tsx index a5814d6f..a391626c 100644 --- a/web/src/components/ProcessTimeline.tsx +++ b/web/src/components/ProcessTimeline.tsx @@ -403,6 +403,16 @@ export function ProcessActivity({ block, onOpenFile, imageAssets, onLoadImage, onInteractionEnd?: (token: number, followOutput?: boolean) => void; onOpenAgent?: (runId: string, title?: string) => void; }) { + if (block.processKind === "compaction" && !block.done && block.status === "running") { + return ( +
+ + 正在压缩上下文 +
+ ); + } if (block.processKind === "agent" && onOpenAgent) { return (
{isMenu && ( -
e.stopPropagation()}> + {capabilities.rename && ( )} @@ -390,7 +395,7 @@ export function SessionsSidebar({ open, engine, space, {space === "work" ? "删除工作" : "删除会话"} )} -
+ )}
); diff --git a/web/src/components/TimedMessageTag.tsx b/web/src/components/TimedMessageTag.tsx new file mode 100644 index 00000000..c852e8a6 --- /dev/null +++ b/web/src/components/TimedMessageTag.tsx @@ -0,0 +1,10 @@ +import type { TimedMessage } from "../protocol"; +import { Icon } from "../icons"; +import "./timed-task.css"; + +export function TimedMessageTag({ task }: { task: TimedMessage }) { + return
+ 定时任务 +
{task.title}计划发送于 {new Date(task.scheduled_at * 1000).toLocaleString()}
+
; +} diff --git a/web/src/components/TimedTaskIndicator.tsx b/web/src/components/TimedTaskIndicator.tsx new file mode 100644 index 00000000..780b9f72 --- /dev/null +++ b/web/src/components/TimedTaskIndicator.tsx @@ -0,0 +1,139 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { TimedTaskInfo } from "../protocol"; +import { Icon } from "../icons"; +import "./timed-task.css"; + +function remaining(seconds: number) { + if (seconds <= 0) return "正在发送"; + if (seconds < 60) return `约 ${Math.ceil(seconds)} 秒后`; + if (seconds < 3600) return `约 ${Math.ceil(seconds / 60)} 分钟后`; + return `约 ${Math.ceil(seconds / 3600)} 小时后`; +} + +function intervalLabel(seconds: number) { + if (seconds % 3600 === 0) return `每 ${seconds / 3600} 小时`; + if (seconds % 60 === 0) return `每 ${seconds / 60} 分钟`; + return `每 ${seconds} 秒`; +} + +/** A task clock is independent of model activity and completion receipts. */ +export function TimedTaskIndicator({ tasks, hidden = false }: { + tasks: TimedTaskInfo[]; hidden?: boolean; +}) { + const buttonRef = useRef(null); + const panelRef = useRef(null); + const closeTimer = useRef | undefined>(undefined); + const [open, setOpen] = useState(false); + const [now, setNow] = useState(() => Date.now() / 1000); + const id = useId(); + const active = tasks.filter(task => task.valid_until > now + && task.sent_count < task.total_count).sort((a, b) => a.next_message_at - b.next_message_at); + const task = active[0]; + const hasTask = !!task; + const visible = open && !hidden && !!task; + useEffect(() => { + if (!hasTask) return; + const timer = window.setInterval(() => setNow(Date.now() / 1000), 1000); + return () => window.clearInterval(timer); + }, [hasTask]); + useEffect(() => { + if (hidden) setOpen(false); + }, [hidden]); + useEffect(() => { + const button = buttonRef.current; + const card = button?.closest(".scard"); + if (!card) return; + const show = () => { clearTimeout(closeTimer.current); setOpen(true); }; + const hide = () => { closeTimer.current = setTimeout(() => setOpen(false), 160); }; + const focus = (event: Event) => { if (event.target === button) show(); }; + const dismiss = (event: PointerEvent) => { + if (!card.contains(event.target as Node) && !panelRef.current?.contains(event.target as Node)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + card.addEventListener("mouseenter", show); + card.addEventListener("mouseleave", hide); + card.addEventListener("focusin", focus); + card.addEventListener("focusout", hide); + document.addEventListener("pointerdown", dismiss); + document.addEventListener("keydown", escape); + return () => { + clearTimeout(closeTimer.current); + card.removeEventListener("mouseenter", show); + card.removeEventListener("mouseleave", hide); + card.removeEventListener("focusin", focus); + card.removeEventListener("focusout", hide); + document.removeEventListener("pointerdown", dismiss); + document.removeEventListener("keydown", escape); + }; + }, [hasTask]); + useLayoutEffect(() => { + const trigger = buttonRef.current; + const card = trigger?.closest(".scard"); + const panel = panelRef.current; + if (!visible || !card || !panel) return; + const viewport = window.visualViewport; + const position = () => { + const box = card.getBoundingClientRect(); + const scroll = card.closest(".s-scroll")!.getBoundingClientRect(); + if (box.bottom <= scroll.top || box.top >= scroll.bottom) { + setOpen(false); + return; + } + const left = (viewport?.offsetLeft ?? 0) + 8; + const top = (viewport?.offsetTop ?? 0) + 8; + const right = left + (viewport?.width ?? window.innerWidth) - 16; + const footer = card.closest(".sessions")?.querySelector(".s-foot")?.getBoundingClientRect(); + const bottom = Math.min(top + (viewport?.height ?? window.innerHeight) - 16, + footer?.top ?? Infinity) - 8; + panel.style.maxWidth = `${Math.max(0, right - left)}px`; + panel.style.maxHeight = `${Math.max(0, bottom - top)}px`; + const size = panel.getBoundingClientRect(); + const beside = box.right + 10 + size.width <= right; + panel.style.left = `${beside ? box.right + 10 : Math.max(left, Math.min(box.right - size.width, right - size.width))}px`; + const below = box.bottom + 8 + size.height <= bottom; + panel.style.top = `${Math.max(top, Math.min(beside ? box.top : below ? box.bottom + 8 : box.top - size.height - 8, bottom - size.height))}px`; + panel.dataset.placement = beside ? "right" : below ? "below" : "above"; + panel.style.visibility = "visible"; + }; + position(); + const observer = new ResizeObserver(position); + observer.observe(card); + observer.observe(panel); + window.addEventListener("resize", position); + window.addEventListener("scroll", position, true); + viewport?.addEventListener("resize", position); + viewport?.addEventListener("scroll", position); + return () => { + observer.disconnect(); + window.removeEventListener("resize", position); + window.removeEventListener("scroll", position, true); + viewport?.removeEventListener("resize", position); + viewport?.removeEventListener("scroll", position); + }; + }, [visible]); + if (!task) return null; + return <> +
-
); } @@ -3110,8 +3103,6 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
)} - {openAsyncQuestion?.scope === asyncQuestionScope && import("./ContextPopover")); interface Props { draftKey: string; draftStore: ComposerDraftStore; + backgroundTasks?: ReactNode; surface?: "code" | "work"; state: State; connState: ConnState; @@ -912,9 +914,10 @@ export function Composer(p: Props) { )} - {busy && ( + {(busy || p.backgroundTasks) && (
-
+ {p.backgroundTasks} + {busy &&
-
+
}
)} diff --git a/web/src/components/ProcessTimeline.tsx b/web/src/components/ProcessTimeline.tsx index a391626c..0f37c5be 100644 --- a/web/src/components/ProcessTimeline.tsx +++ b/web/src/components/ProcessTimeline.tsx @@ -535,29 +535,6 @@ export function ProcessActivity({ block, onOpenFile, imageAssets, onLoadImage, ); } -export function BackgroundProcessDock({ processes, onOpenFile, onOpenAgent }: { - processes: ProcessBlock[]; - onOpenFile?: (path: string, line?: number) => void; - onOpenAgent?: (runId: string, title?: string) => void; -}) { - if (processes.length === 0) return null; - return ( - - ); -} - function TimelineItem({ block, onOpenFile, imageAssets, onLoadImage, onAuthorizeImage, onPreviewImage, historyTurnId, historyImageAssets, onLoadHistoryImage, diff --git a/web/src/index.css b/web/src/index.css index 2e421faa..4a766476 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -315,19 +315,6 @@ body{ /* THREAD */ .thread-shell{ flex:1; min-height:0; position:relative; display:flex; flex-direction:column; } .thread-frame{ flex:1; min-height:0; position:relative; display:flex; } -.background-process-dock{ flex:none; position:relative; z-index:3; width:min(760px,calc(100% - 32px)); - margin:0 auto 10px; padding:9px 11px; border:1px solid var(--line); border-radius:14px; - background:color-mix(in srgb,var(--raised) 94%,transparent); box-shadow:0 8px 24px rgba(0,0,0,.08); } -.background-process-head{ display:flex; align-items:center; gap:8px; min-height:24px; color:var(--muted); - font-size:12px; font-weight:650; } -.background-process-pulse{ width:8px; height:8px; border-radius:50%; background:var(--accent); - box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 42%,transparent); animation:background-process-pulse 1.6s ease-out infinite; } -.background-process-count{ margin-left:auto; min-width:20px; padding:1px 6px; border-radius:999px; - background:var(--surface); color:var(--muted); text-align:center; } -.background-process-items{ display:flex; flex-direction:column; gap:3px; margin-top:4px; - max-height:min(32vh,240px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin; } -.background-process-items .process-activity{ margin:0; } -@keyframes background-process-pulse{ 70%,100%{ box-shadow:0 0 0 7px transparent; } } .assistant-answer-segment{ display:contents; } .background-followup-boundary{ display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin:14px 0 8px; padding-top:10px; border-top:1px solid var(--line); color:var(--muted); font-size:11px; line-height:1.4; } @@ -1502,7 +1489,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .auto-compact-sheet .auto-compact-options button{ min-height:38px; font-size:12px; } .auto-compact-sheet .auto-compact-meta{ font-size:11px; } -/* run bar (running only) */ +/* Composer activity: send mode and independently running background tasks. */ .runbar{ display:none; align-items:center; gap:8px; padding:2px 2px 9px; } .runbar.show{ display:flex; } .stopbtn{ display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:600; diff --git a/web/tests/background-tasks.spec.ts b/web/tests/background-tasks.spec.ts new file mode 100644 index 00000000..b7ca295a --- /dev/null +++ b/web/tests/background-tasks.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from "@playwright/test"; + +const url = "/tests/history-browser.html?background-tasks=1"; +const trigger = (page: Page) => page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }); +const dialog = (page: Page) => page.getByRole("dialog", { name: "后台任务", exact: true }); + +test("background tasks keep the composer compact and open readable details", async ({ page }, testInfo) => { + await page.goto(url); + const chip = trigger(page); + await expect(chip).toHaveText("后台任务 · 2"); + await expect(dialog(page)).toHaveCount(0); + const before = await page.locator(".thread-shell").boundingBox(); + const chipBox = (await chip.boundingBox())!; + const modeBox = (await page.locator(".runbar .seg").boundingBox())!; + expect(Math.abs(chipBox.y + chipBox.height / 2 - modeBox.y - modeBox.height / 2)).toBeLessThan(2); + expect(chipBox.x + chipBox.width).toBeLessThan(modeBox.x); + expect(chipBox.height).toBeLessThanOrEqual(40); + await chip.click(); + const panel = dialog(page); + await expect(panel).toBeVisible(); + await expect(panel.getByText("检查构建进度和板载温度", { exact: true })).toBeVisible(); + await expect(panel.getByText(/运行中 · 2分/)).toBeVisible(); + if ((page.viewportSize()?.width ?? 0) <= 600) { + await page.keyboard.press("Tab"); + await expect(panel.getByRole("button", { name: "关闭后台任务" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(panel.locator("summary")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(panel.getByRole("button", { name: /核对构建产物/ })).toBeFocused(); + } + await panel.locator("summary").click(); + await expect(panel.getByText("$ make verify", { exact: true })).toBeVisible(); + const after = await page.locator(".thread-shell").boundingBox(); + expect(after).toEqual(before); + const panelBox = (await panel.boundingBox())!; + const mobile = (page.viewportSize()?.width ?? 0) <= 600; + if (mobile) { + await expect(panel).toHaveAttribute("aria-modal", "true"); + expect(Math.abs(panelBox.x + panelBox.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2); + const viewport = await page.locator(".thread-shell").boundingBox(); + const visualCenter = await page.evaluate(() => (window.visualViewport?.offsetTop ?? 0) + + (window.visualViewport?.height ?? window.innerHeight) / 2); + const center = panelBox.y + panelBox.height / 2; + // Short chat areas may use the full visible viewport to keep details usable. + expect(Math.min(Math.abs(center - viewport!.y - viewport!.height / 2), + Math.abs(center - visualCenter))).toBeLessThan(2); + } else { + await expect(panel).toHaveAttribute("data-placement", "above"); + expect(panelBox.y + panelBox.height).toBeLessThan(chipBox.y); + expect(Math.abs(panelBox.x - chipBox.x)).toBeLessThan(2); + } + await page.screenshot({ path: testInfo.outputPath("background-tasks-open.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(chip).toBeFocused(); +}); + +test("background tasks follow native terminal events and empty snapshots", async ({ page }) => { + await page.goto(url); + await page.getByRole("button", { name: "回复结束", exact: true }).click(); + await expect(page.locator(".runbar .seg")).toHaveCount(0); + await expect(trigger(page)).toHaveText("后台任务 · 2"); + await trigger(page).click(); + // Native updates can arrive while a modal is open, without a user click. + await page.getByRole("button", { name: "构建完成", exact: true }).evaluate(button => (button as HTMLButtonElement).click()); + await expect(trigger(page)).toHaveText("后台任务 · 1"); + await expect(dialog(page)).toBeVisible(); + await expect(dialog(page).getByText("检查构建进度和板载温度", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "代理完成", exact: true }).evaluate(button => (button as HTMLButtonElement).click()); + await expect(trigger(page)).toHaveCount(0); + await expect(dialog(page)).toHaveCount(0); + await expect(page.locator(".runbar")).toHaveCount(0); + await expect(page.locator(".thread")).toContainText("构建还在后台运行"); + + await page.getByRole("button", { name: "多个任务", exact: true }).click(); + await expect(trigger(page)).toHaveText("后台任务 · 12"); + await expect(dialog(page)).toHaveCount(0); + await trigger(page).click(); + const list = dialog(page).locator(".background-task-list"); + expect(await list.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true); + await page.getByRole("button", { name: "空快照", exact: true }).evaluate(button => (button as HTMLButtonElement).click()); + await expect(trigger(page)).toHaveCount(0); + await expect(dialog(page)).toHaveCount(0); +}); + +test("background tasks preserve draft input and reset open state on session changes", async ({ page }) => { + await page.goto(url); + const input = page.locator(".inrow textarea"); + await input.fill("继续检查这次构建"); + await trigger(page).click(); + await dialog(page).getByRole("button", { name: "关闭后台任务", exact: true }).click(); + await expect(input).toHaveValue("继续检查这次构建"); + await trigger(page).click(); + await page.getByRole("button", { name: "切换会话", exact: true }).evaluate(button => (button as HTMLButtonElement).click()); + await expect(dialog(page)).toHaveCount(0); + await expect(trigger(page)).toHaveText("后台任务 · 2"); + await trigger(page).click(); + await dialog(page).getByRole("button", { name: /核对构建产物/ }).click(); + await expect(page.getByTestId("background-opened")).toHaveText("agent"); + await expect(dialog(page)).toHaveCount(0); +}); + +test("background tasks stay within the mobile viewport after keyboard resize", async ({ page }) => { + test.skip((page.viewportSize()?.width ?? 0) > 600, "Mobile keyboard layout"); + await page.goto(url); + await page.getByRole("button", { name: "多个任务", exact: true }).click(); + await trigger(page).click(); + const originalHeight = (await dialog(page).boundingBox())!.height; + const width = page.viewportSize()!.width; + const height = page.viewportSize()!.height; + await page.setViewportSize({ width, height: 380 }); + await expect.poll(async () => { + const box = (await dialog(page).boundingBox())!; + return box.y >= 0 && box.y + box.height <= 380; + }).toBe(true); + await page.setViewportSize({ width, height }); + await expect.poll(async () => (await dialog(page).boundingBox())!.height).toBeCloseTo(originalHeight, 0); + await expect(dialog(page).locator(".background-task-list")).toBeVisible(); +}); + +test("background tasks show a small desktop hover preview and honor reduced motion", async ({ page }) => { + test.skip((page.viewportSize()?.width ?? 0) <= 600, "Desktop hover"); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(url + "&theme=light"); + await trigger(page).hover(); + await expect(page.getByRole("tooltip")).toContainText("检查构建进度和板载温度"); + await expect(dialog(page)).toHaveCount(0); + expect(await page.locator(".background-task-indicator").evaluate(node => getComputedStyle(node).animationName)).toBe("none"); + await trigger(page).click(); + await page.locator(".inrow textarea").click(); + await expect(dialog(page)).toHaveCount(0); + await expect(page.locator(".inrow textarea")).toBeFocused(); +}); diff --git a/web/tests/claude-background-process.test.ts b/web/tests/claude-background-process.test.ts index ca22b33b..196e4609 100644 --- a/web/tests/claude-background-process.test.ts +++ b/web/tests/claude-background-process.test.ts @@ -149,27 +149,6 @@ try { "untrusted edge volume cannot grow the detached-task dock past protocol bounds", ); - const dockMarkup = renderToStaticMarkup(createElement(ChatView, { - sid: "background-dock", - turns: [], - engine: "claude", - backgroundProcesses: [{ - kind: "process", - item_id: "bash-task", - processKind: "task", - phase: "snapshot", - status: "running", - title: "Run verification", - command: "make verify", - background: true, - done: false, - }], - })); - assert.match(dockMarkup, /后台任务正在运行/); - assert.match(dockMarkup, /Run verification/); - assert.match(dockMarkup, /make verify/, - "the detached Bash card reuses the expandable command UI"); - const followupMarkup = renderToStaticMarkup(createElement(ChatView, { sid: "followup-history", engine: "claude", diff --git a/web/tests/fixtures/background-tasks.tsx b/web/tests/fixtures/background-tasks.tsx new file mode 100644 index 00000000..b0a0a046 --- /dev/null +++ b/web/tests/fixtures/background-tasks.tsx @@ -0,0 +1,92 @@ +import { useEffect, useReducer, useRef, useState } from "react"; +import { Composer } from "../../src/components/Composer"; +import { ChatView } from "../../src/components/ChatView"; +import BackgroundTaskControl from "../../src/components/BackgroundTaskControl"; +import { ComposerDraftStore } from "../../src/composer-drafts"; +import { createRuntime, initialState, reduce, type AppState } from "../../src/reducer"; +import { PROTOCOL_VERSION, type ServerEvent } from "../../src/protocol"; +import { useMobileViewport } from "../../src/use-mobile-viewport"; + +function event(sid: string, payload: Record): ServerEvent { + return { v: PROTOCOL_VERSION, ts: Date.now() / 1000, sid, ...payload } as ServerEvent; +} + +function initial(): AppState { + let state: AppState = { ...initialState, runtimes: {} }; + for (const sid of ["background-a", "background-b"]) { + state.runtimes[sid] = createRuntime(); + for (const payload of [ + { type: "user_msg", msg_id: "parent", prompt: "检查构建进度和板载温度" }, + { type: "state", state: "running" }, + { type: "process", item_id: "build", kind: "task", phase: "start", status: "running", + turn_id: "parent", title: "检查构建进度和板载温度", command: "make verify", + background: true, ts: Date.now() / 1000 - 154 }, + { type: "process", item_id: "agent", kind: "agent", phase: "start", status: "running", + turn_id: "parent", title: "核对构建产物", summary: "后台检查仍在继续", background: true }, + { type: "assistant_msg_start", message_id: "answer", turn_id: "parent", channel: "final" }, + { type: "delta", message_id: "answer", turn_id: "parent", channel: "final", + text: "构建还在后台运行,我会在完成后继续核对结果。" }, + { type: "assistant_msg_end", message_id: "answer", turn_id: "parent", channel: "final" }, + ]) state = reduce(state, { type: "event", event: event(sid, payload) }); + } + return state; +} + +export function BackgroundTasksFixture() { + useMobileViewport(); + const [state, dispatch] = useReducer(reduce, undefined, initial); + const [sid, setSid] = useState("background-a"); + const [opened, setOpened] = useState(""); + const drafts = useRef(new ComposerDraftStore()); + const runtime = state.runtimes[sid]; + useEffect(() => { + document.documentElement.dataset.engine = "claude"; + document.documentElement.dataset.theme = new URLSearchParams(location.search).get("theme") ?? "dark"; + }, []); + const emit = (payload: Record) => dispatch({ type: "event", event: event(sid, payload) }); + const complete = (itemId: string, kind: "task" | "agent") => emit({ + type: "process", item_id: itemId, kind, phase: "end", status: "succeeded", + turn_id: "parent", title: itemId === "build" ? "检查构建进度和板载温度" : "核对构建产物", + summary: "任务已完成", background: true, + }); + return
+
+ + + + + + + {opened} +
+
Code
+ setOpened(id)} /> + 0 + ? setOpened(id)} /> : null} + connState="connected" wrapperOnline sendMode={runtime.sendMode} + setSendMode={mode => dispatch({ type: "set_send_mode", sid, mode })} + queue={[]} pendingSend={null} failedDeferred={[]} + unconfirmedQueued={[]} unconfirmedReplaceable={[]} + queueCapacity={{}} replaceQueueCapacity={{}} + model="claude-fable-5-1" effort="max" perm="bypassPermissions" + permissionProfile={null} permissionProfiles={null} webSearch={null} + collaborationMode="default" engine="claude" editPrompt={null} + onEditConsumed={() => {}} onSendQuery={() => false} onSteerQuery={() => false} + onInterrupt={() => {}} onEnqueue={() => false} onSetPending={() => false} + onRemoveQueued={() => {}} onInspectQueued={() => {}} onSetModel={() => {}} + onSetEffort={() => {}} onSetPerm={() => {}} onSetPermissionProfile={() => {}} + onGetPermissionProfiles={() => {}} onSetWebSearch={() => {}} + onSetCollaborationMode={() => {}} onClear={() => {}} onContext={() => {}} + contextReport={null} /> +
; +} diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 0956d2f6..4d8b1220 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { createRoot } from "react-dom/client"; +import { BackgroundTasksFixture } from "./fixtures/background-tasks"; import "../src/index.css"; import "../src/App.css"; @@ -2088,6 +2089,7 @@ function HistoryConversationBrowserFixture() { export function HistoryBrowserFixture() { const params = new URLSearchParams(window.location.search); + if (params.has("background-tasks")) return ; const planUi = params.get("plan-ui"); if (params.has("profile-sidebar")) return ; if (planUi) return ; diff --git a/web/tests/history-browser.spec.ts b/web/tests/history-browser.spec.ts index 9e033678..9b5a2ec3 100644 --- a/web/tests/history-browser.spec.ts +++ b/web/tests/history-browser.spec.ts @@ -2703,6 +2703,7 @@ test("right panel layout preserves Claude agent detail priority over side chats" items: [{ item_id: "layout-agent", kind: "agent", status: "running", title: "Layout child agent" }] }); const agentCard = page.getByRole("button", { name: "Layout child agent" }); + await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click(); await agentCard.click(); await expect(page.locator(".agent-detail-panel")).toBeVisible(); await expect(page.locator(".btw-panel")).toHaveCount(0); @@ -2712,6 +2713,7 @@ test("right panel layout preserves Claude agent detail priority over side chats" await expectRightPanelSpace(page, true); await page.getByRole("button", { name: "收起侧边对话", exact: true }).click(); await expectRightPanelSpace(page, false); + await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click(); await agentCard.click(); await expect(page.locator(".agent-detail-panel")).toBeVisible(); await expectRightPanelSpace(page, true); @@ -2731,6 +2733,7 @@ async function coverBtwPanel( relay.emit({ type: "background_process_sync", sid: "layout-parent", items: [{ item_id: "layout-agent", kind: "agent", status: "running", title: "Layout child agent" }] }); + await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click(); await page.getByRole("button", { name: "Layout child agent" }).click(); await expect(page.locator(".agent-detail-panel")).toBeVisible(); } else { diff --git a/web/tests/scroll-follow.test.ts b/web/tests/scroll-follow.test.ts index 3dde835e..51ccb4eb 100644 --- a/web/tests/scroll-follow.test.ts +++ b/web/tests/scroll-follow.test.ts @@ -591,14 +591,9 @@ const threadShellRule = css.match(/\.thread-shell\{[^}]+\}/)?.[0] ?? ""; assert.match(threadShellRule, /position:relative/); const threadFrameRule = css.match(/\.thread-frame\{[^}]+\}/)?.[0] ?? ""; assert.match(threadFrameRule, /position:relative/, - "the scroll-to-bottom overlay must be scoped above the thread, not the task dock"); + "the scroll-to-bottom overlay must stay scoped to the thread"); assert.match(threadFrameRule, /min-height:0/, - "the task dock must not force the scroll viewport beyond the composer"); -assert.match( - chatViewSource, - /className="thread-frame"[\s\S]*className="scroll-bottom-wrap"[\s\S]* Date: Tue, 15 Sep 2026 22:10:41 +0800 Subject: [PATCH 03/17] fix(claude): request readable SDK thinking summaries - Set the native thinking display option for new, resumed and private-fork sessions. - Preserve native thinking mode, token budget and effort. - Cover native SDK launch arguments and connection lifecycle regressions. --- CHANGELOG.md | 4 +++ CHANGELOG_zh.md | 3 ++ cc_remote/wrapper/sdk.py | 9 ++++- tests/test_child_env.py | 2 ++ tests/test_claude_rewind.py | 1 + tests/test_claude_thinking.py | 62 +++++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/test_claude_thinking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c38a7c..a06e1d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +- Request readable Claude thinking summaries in SDK sessions and show returned + text in the existing thinking timeline. Preserve native thinking mode, + token budget and effort across new sessions, resume and private forks. + - Accept provider-native Claude model ids (e.g. `glm-5.2` behind a custom `ANTHROPIC_BASE_URL` gateway) as explicit model selections: they are handed to Claude Code, persisted in the private session store, and restored across diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index ea9237ff..66a00987 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -4,6 +4,9 @@ ## 未发布 +- Claude SDK 会话明确请求思考摘要,返回内容显示在现有“思考”区域;新建、恢复 + 和私有分叉均保留原生思考模式、token 预算和强度。 + - 支持把提供商原生模型 ID(例如自定义 `ANTHROPIC_BASE_URL` 网关下的 `glm-5.2`)作为显式模型选择:它会传给 Claude Code、写入私有会话记录,并在 重连和冷恢复后保留。仅在转录或 `/context` 元数据中“观测到”的模型 ID 依旧 diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py index 0b8660ed..c71501d6 100644 --- a/cc_remote/wrapper/sdk.py +++ b/cc_remote/wrapper/sdk.py @@ -367,7 +367,14 @@ def _options( "enter plan mode for them.\n" "Modes: default, acceptEdits, plan, auto, bypassPermissions." ) - extra_args = {"replay-user-messages": None} + # showThinkingSummaries is an interactive CLI preference. SDK sessions + # must request the readable summary explicitly. Pass only display here: + # setting thinking={type: adaptive, ...} would also override the user's + # native thinking mode/budget instead of just making its output visible. + extra_args = { + "replay-user-messages": None, + "thinking-display": "summarized", + } auto_compact_mode, auto_compact_threshold = ( auto_compact_override if auto_compact_override is not None diff --git a/tests/test_child_env.py b/tests/test_child_env.py index 6a77fb3e..0204d732 100644 --- a/tests/test_child_env.py +++ b/tests/test_child_env.py @@ -351,6 +351,7 @@ def test_claude_work_uses_minimal_isolated_runtime(): assert options.sandbox is None assert options.extra_args == { "replay-user-messages": None, + "thinking-display": "summarized", "safe-mode": None, } assert options.system_prompt == WORK_SYSTEM_PROMPT @@ -701,6 +702,7 @@ def test_claude_code_keeps_official_prompt_preset_and_runtime_surface(): assert options.hooks is None assert options.extra_args == { "replay-user-messages": None, + "thinking-display": "summarized", } diff --git a/tests/test_claude_rewind.py b/tests/test_claude_rewind.py index 9618fc23..25cf8874 100644 --- a/tests/test_claude_rewind.py +++ b/tests/test_claude_rewind.py @@ -33,6 +33,7 @@ def test_claude_options_enable_file_checkpoints_and_user_message_replay(): assert options.enable_file_checkpointing is True assert options.extra_args == { "replay-user-messages": None, + "thinking-display": "summarized", } diff --git a/tests/test_claude_thinking.py b/tests/test_claude_thinking.py new file mode 100644 index 00000000..720011b5 --- /dev/null +++ b/tests/test_claude_thinking.py @@ -0,0 +1,62 @@ +"""Request readable Claude summaries without changing native thinking controls.""" + +from __future__ import annotations + +import asyncio + +import pytest +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport + +from cc_remote.config import WrapperConfig +from cc_remote.wrapper import sdk as sdk_module +from cc_remote.wrapper.sdk import SdkHandle +from tests.test_claude_permission_state import _FakeClaudeClient + + +@pytest.mark.parametrize("profile", ["legacy", "isolated", "work"]) +@pytest.mark.parametrize("resume", [None, "12345678-1234-4234-8234-123456789abc"]) +def test_summary_display_reaches_cli_without_overriding_thinking( + tmp_path, monkeypatch, profile, resume, +): + monkeypatch.setenv("MAX_THINKING_TOKENS", "0") + handle = SdkHandle( + WrapperConfig(claude_bin=str(tmp_path / "claude")), + claude_config_dir=str(tmp_path / "account") if profile == "isolated" else None, + isolate_account_env=profile == "isolated", + ) + handle.work_mode = profile == "work" + handle.permission_mode = "plan" + options = handle._options(resume, str(tmp_path), effort_override="low") + # Inspect the real pinned SDK's argv without launching a CLI or model. + argv = SubprocessCLITransport(prompt="unused", options=options)._build_command() + + assert argv.count("--thinking-display") == 1 + assert argv[argv.index("--thinking-display") + 1] == "summarized" + assert "--thinking" not in argv + assert "--max-thinking-tokens" not in argv + assert options.thinking is None + assert options.max_thinking_tokens is None + assert options.effort == "low" + assert options.permission_mode == "plan" + + +def test_summary_display_survives_connect_reconnect_and_private_fork(monkeypatch): + async def go(): + _FakeClaudeClient.created = [] + monkeypatch.setattr(sdk_module, "ClaudeSDKClient", _FakeClaudeClient) + handle = SdkHandle(WrapperConfig()) + handle.effort = "low" + try: + await handle.connect(cwd="/tmp") + await handle.force_reconnect(None, "/tmp", reason="test") + for client in _FakeClaudeClient.created: + assert client.options.extra_args["thinking-display"] == "summarized" + assert client.options.thinking is None + assert client.options.effort == "low" + fork = handle._options("parent", "/tmp", fork=True) + assert fork.extra_args["thinking-display"] == "summarized" + assert fork.fork_session is True + finally: + await handle.disconnect() + + asyncio.run(go()) From bad9f205ba80025c369eb37b83fb3c55ab78a014 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Wed, 16 Sep 2026 11:25:30 +0800 Subject: [PATCH 04/17] fix(claude): preserve background reply ownership and completion state - Route child task updates to their agent and prevent replayed completions from reopening sessions. - Bind autonomous replies to the originating turn and preserve source completion times. - Rebuild affected Claude history projections and cover managed, live, and recovery paths. --- cc_remote/wrapper/claude_agents.py | 16 ++++ cc_remote/wrapper/history_store.py | 13 +++- cc_remote/wrapper/machine.py | 62 ++++++++------- cc_remote/wrapper/stream.py | 5 +- tests/test_claude_autocompact.py | 116 ++++++++++++++++++++++++++++- tests/test_claude_rich_stream.py | 4 +- tests/test_history.py | 42 +++++++++++ tests/test_history_store.py | 48 ++++++++---- 8 files changed, 258 insertions(+), 48 deletions(-) diff --git a/cc_remote/wrapper/claude_agents.py b/cc_remote/wrapper/claude_agents.py index 66e3f256..8c452c66 100644 --- a/cc_remote/wrapper/claude_agents.py +++ b/cc_remote/wrapper/claude_agents.py @@ -171,6 +171,7 @@ def __init__(self, tool_result_max: int): self.run_by_tool: dict[str, str] = {} self.run_by_agent: dict[str, str] = {} self.tool_owner: dict[str, str] = {} + self.task_owner: dict[str, str] = {} def _ensure_run( self, @@ -354,6 +355,21 @@ def route(self, message: object) -> AgentRoute: )): run = self._run_for_task(message) if run is None: + # Child Bash tasks are forwarded as top-level SDK system + # messages, without parent_tool_use_id. Their tool identity + # still belongs to the child. Keep later task-id-only updates + # there too; they cannot reserve a response in the main turn. + tool_id = _tool_id_from_data(message) + task_id = getattr(message, "task_id", None) + owner = self.runs.get( + self.tool_owner.get(tool_id or "", "") + or self.task_owner.get(task_id, "")) + if owner is not None: + if isinstance(task_id, str) and task_id: + self.task_owner[task_id] = owner.run_id + while len(self.task_owner) > _MAX_AGENT_TOOL_OWNERS: + self.task_owner.pop(next(iter(self.task_owner))) + return self._record_detail_events(owner, message) return AgentRoute("main") if isinstance(message, TaskStartedMessage): self._bind_agent(run, message.task_id) diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py index 13732239..93708aed 100644 --- a/cc_remote/wrapper/history_store.py +++ b/cc_remote/wrapper/history_store.py @@ -63,7 +63,9 @@ # Rebuild pages which promoted an RPC-accepted steer's presentation clock to # process presence before the native user segment existed. # v39 hides local command caveats and preserves the pre-compact answer clock. -_SCHEMA_VERSION = 39 +# v40 gives real Claude background replies their source completion time while +# retaining the old boundary for task bookkeeping without a reply. +_SCHEMA_VERSION = 40 _FINGERPRINT_SAMPLE_BYTES = 64 * 1024 _DEFAULT_MAX_ENTRIES = 128 _DEFAULT_MAX_BYTES = 64 * 1024 * 1024 @@ -1284,6 +1286,11 @@ def _select_page_row( def _ensure_schema(self) -> None: with self._connect() as connection: current = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if current in range(10, 40): + # Real background replies had the original answer's timestamp. + # Only Claude narrative changes; keep all source-bound assets. + for table in ("history_pages", "history_turn_details"): + connection.execute(f"DELETE FROM {table} WHERE engine='claude'") if current in range(10, 39): # Native /compact envelopes are not human turns. Rebuild the # Claude narrative and cached visible-user graph together. @@ -1417,8 +1424,8 @@ def _ensure_schema(self) -> None: for table in ("history_pages", "history_turn_details"): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") - elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38): - # The independent v22-v39 invalidations above suffice. + elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39): + # The independent v22-v40 invalidations above suffice. pass elif current not in (0, _SCHEMA_VERSION): # v9 changes the invariant of history_turn_details: those rows diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index c07a0f82..b1b27f28 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -69,6 +69,7 @@ ResultMessage, SystemMessage, TaskNotificationMessage, TaskProgressMessage, TaskStartedMessage, TaskUpdatedMessage, RateLimitEvent, TERMINAL_TASK_STATUSES, UserMessage, + ToolResultBlock, ServerToolResultBlock, ) from cc_remote.attachments import ( @@ -6505,6 +6506,17 @@ def _retire_claude_followup( return True return False + @staticmethod + def _claude_injected_user_boundary(message: object) -> bool: + if not isinstance(message, UserMessage) or message.parent_tool_use_id: + return False + origin = getattr(message, "origin", None) + if not isinstance(origin, dict) or origin.get("kind") in (None, "human"): + return False + return not (isinstance(message.content, list) and any( + isinstance(block, (ToolResultBlock, ServerToolResultBlock)) + for block in message.content)) + def _observe_claude_task_lifecycle( self, ctx: SessionContext, @@ -6542,16 +6554,7 @@ def _observe_claude_task_lifecycle( return if isinstance(message, UserMessage): - origin = getattr(message, "origin", None) - origin_kind = ( - origin.get("kind") if isinstance(origin, dict) else None - ) - if ( - background - and isinstance(origin_kind, str) - and origin_kind != "human" - and not getattr(message, "parent_tool_use_id", None) - ): + if background and self._claude_injected_user_boundary(message): # The pinned SDK marks every injected turn at its replayed # top-level user boundary. This is stronger than inferring a # continuation only from the last active task: channel/peer @@ -6585,11 +6588,15 @@ def _observe_claude_task_lifecycle( # injected turn: that owner still requires its exact Result. if ctx.claude_background_followups.get(key) == "notified": ctx.claude_background_followups.pop(key, None) - elif background and isinstance(message, TaskNotificationMessage): + elif (background and isinstance(message, TaskNotificationMessage) + and not getattr(message, "_cc_service_seed", False)): # A task_updated terminal is a task status, not evidence of a # new model response. In particular killed tasks often emit no # notification and no Result at all. Only a real notification # can reserve the subsequent injected User/Result boundary. + # Reattach seeds describe already-consumed task state, not a + # fresh notification. Any unacknowledged continuation follows + # separately in the service's ordered User/Result replay. overflowed = self._claim_claude_followup_notification( ctx, task_id) if overflowed: @@ -21487,20 +21494,6 @@ async def _on_claude_background_message( await self._observe_claude_model_fallback(ctx, message) is_result = isinstance(message, ResultMessage) followup_was_pending = self._claude_autonomous_followup_pending(ctx) - self._observe_claude_task_lifecycle( - ctx, message, background=True) - followup_is_pending = self._claude_autonomous_followup_pending(ctx) - if not is_result and followup_is_pending and not followup_was_pending: - # Parent Result may already have published idle, or may still be in - # its finalizer. Either way the autonomous continuation is now the - # real running owner and Stop must remain available. - if ctx.state == "idle": - await self._set_state(ctx, "running") - elif ctx.state in {"interrupting", "draining"}: - # Stop was accepted against the parent just before the task - # notification exposed its autonomous continuation. Extend the - # same absolute drain deadline to that newly-visible owner. - self._schedule_claude_autonomous_interrupt_watchdog(ctx) try: thread_id = self._ctx_wire_sid(ctx) route = AgentRoute("main") @@ -21517,8 +21510,23 @@ async def _on_claude_background_message( await self._publish_claude_agent_route(ctx, route) if route.target != "detail": + self._observe_claude_task_lifecycle( + ctx, message, background=True) + followup_is_pending = self._claude_autonomous_followup_pending(ctx) + if not is_result and followup_is_pending and not followup_was_pending: + # Only a main-session continuation owns its running + # state. A child's background command has its own + # notification/Result consumer inside that Agent. + if ctx.state == "idle": + await self._set_state(ctx, "running") + elif ctx.state in {"interrupting", "draining"}: + self._schedule_claude_autonomous_interrupt_watchdog(ctx) translator = ctx.claude_background_translator - if translator is None: + if translator is None or self._claude_injected_user_boundary(message): + # Idle system updates can create a translator before + # any user turn exists. Rebind at the native injected + # boundary so the answer extends its actual owner, + # never a prompt-less row with a stale/empty identity. translator = StreamTranslator( self.cfg.tool_result_max, turn_id=turn_id, @@ -38056,7 +38064,6 @@ async def msg_stream(): if goal_changed and ctx.goal_visible: await self._emit(ctx, GoalState(goal=goal)) - self._observe_claude_task_lifecycle(ctx, msg) registry = ctx.claude_agents route = ( registry.route(msg) @@ -38065,6 +38072,7 @@ async def msg_stream(): await self._publish_claude_agent_route(ctx, route) if route.target == "detail": continue + self._observe_claude_task_lifecycle(ctx, msg) native_user_id = replayed_user_message_id(msg) if native_user_id is not None: diff --git a/cc_remote/wrapper/stream.py b/cc_remote/wrapper/stream.py index afb130ff..f1cfa907 100644 --- a/cc_remote/wrapper/stream.py +++ b/cc_remote/wrapper/stream.py @@ -3401,7 +3401,10 @@ def close_turn( if ( mts is not None and advance_terminal_clock - and not background_followup + # A notification alone cannot extend a settled answer. Actual + # assistant output after it is a new conversational continuation + # and must advance the footer to its real source time. + and (not background_followup or role == "assistant") ): last_ts = mts # Claude's transcript does not persist the SDK ResultMessage. EOF normally diff --git a/tests/test_claude_autocompact.py b/tests/test_claude_autocompact.py index 8b67323a..5eed34bc 100644 --- a/tests/test_claude_autocompact.py +++ b/tests/test_claude_autocompact.py @@ -2113,11 +2113,16 @@ def result(origin: dict) -> ResultMessage: asyncio.run(run()) -def test_autonomous_followup_streams_text_and_tools_without_duplicate_turn_end(): +@pytest.mark.parametrize("stale_owner", [None, "previous-human-turn"]) +def test_autonomous_followup_streams_text_and_tools_without_duplicate_turn_end(stale_owner): async def run(): machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk()) origin = {"kind": "task-notification"} assistant_id = "77777777-7777-4777-8777-777777777777" + # Idle task/status messages may have constructed this translator long + # before the current human prompt. Its text owner must be rebound. + ctx.claude_background_translator = StreamTranslator( + 1024, turn_id=stale_owner) await machine._on_claude_background_message( ctx, @@ -2169,7 +2174,7 @@ async def run(): content="contents", is_error=False, )], - parent_tool_use_id="background-read", + origin=origin, ), "origin-turn", ) @@ -2216,6 +2221,113 @@ async def run(): asyncio.run(run()) +@pytest.mark.parametrize("child_command", [False, True]) +def test_managed_turn_tracks_only_its_own_background_commands(child_command): + from cc_remote.wrapper.claude_agents import ClaudeAgentRegistry + + async def run(): + class TaskSdk(_AutoCompactSdk): + async def query(self, _prompt): + return None + + async def refresh_goal(self, _session_id): + return None + + async def receive_response(self): + if child_command: + yield AssistantMessage( + content=[ToolUseBlock(id="review", name="Agent", input={})], + model="claude-test") + yield AssistantMessage( + content=[ToolUseBlock(id="check", name="Bash", input={ + "command": "make check", "run_in_background": True})], + model="claude-test", + parent_tool_use_id="review" if child_command else None) + yield TaskStartedMessage( + subtype="task_started", data={}, task_id="check-task", + tool_use_id="check", task_type="local_bash", + description="checks", uuid="check-start", session_id=SESSION_ID) + yield ResultMessage( + subtype="success", duration_ms=1, duration_api_ms=1, + is_error=False, num_turns=1, session_id=SESSION_ID) + + machine, transport, ctx = _machine_with_sdk(TaskSdk()) + ctx.claude_agents = ClaudeAgentRegistry(1024) + ctx.state = "running" + ctx.active_msg_id = "managed-review" + await asyncio.wait_for(machine._run_turn(ctx, "review"), timeout=1) + assert not [event for event in transport.sent if isinstance(event, Error)] + assert ctx.state == "idle" + assert ctx.claude_active_tasks == (set() if child_command else {"check-task"}) + + asyncio.run(run()) + + +def test_child_background_command_does_not_claim_a_main_followup(): + from cc_remote.wrapper.claude_agents import ClaudeAgentRegistry + from cc_remote.wrapper.stream import public_agent_run_id + + async def run(): + machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk()) + registry = ctx.claude_agents = ClaudeAgentRegistry(1024) + registry.route(AssistantMessage( + content=[ToolUseBlock(id="review", name="Agent", input={})], + model="claude-test")) + registry.route(AssistantMessage( + content=[ToolUseBlock(id="child-check", name="Bash", input={ + "command": "make check", "run_in_background": True})], + model="claude-test", parent_tool_use_id="review")) + # Preserve the real parent task while isolating all of the child's + # background lifecycle, including an update without a tool-use id. + ctx.claude_active_tasks.add("review-task") + messages = [ + TaskStartedMessage( + subtype="task_started", data={}, task_id="child-task", + tool_use_id="child-check", task_type="local_bash", + description="checks", uuid="child-start", session_id=SESSION_ID), + TaskUpdatedMessage( + subtype="task_updated", data={}, task_id="child-task", + patch={"status": "running"}, status="running"), + TaskNotificationMessage( + subtype="task_notification", data={}, task_id="child-task", + tool_use_id="child-check", status="completed", output_file="", + summary="checks passed", uuid="child-end", session_id=SESSION_ID), + ] + for message in messages: + await machine._on_claude_background_message(ctx, message, "human-turn") + assert ctx.claude_background_followups == {} + assert ctx.claude_active_tasks == {"review-task"} + assert ctx.state == "idle" + assert not any(isinstance(event, ProcessEvent) for event in transport.sent) + child = registry.snapshot(public_agent_run_id("review")) + assert child is not None + assert any(event.get("item_id") == "child-task" for event in child.events) + + asyncio.run(run()) + + +def test_completed_service_seed_does_not_reopen_an_idle_session(): + async def run(): + machine, _transport, ctx = _machine_with_sdk(_AutoCompactSdk()) + message = TaskNotificationMessage( + subtype="task_notification", data={}, task_id="old-task", + status="completed", output_file="", summary="already done", + uuid="seed", session_id=SESSION_ID, tool_use_id="old-tool") + message._cc_service_seed = True + await machine._on_claude_background_message(ctx, message, "old-turn") + assert ctx.claude_background_followups == {} + assert ctx.state == "idle" + # An actual unacknowledged continuation is still authoritative even + # when its preceding notification was only a reattachment seed. + await machine._on_claude_background_message(ctx, UserMessage( + content="task completed", origin={ + "kind": "task-notification", "taskId": "old-task"}), "old-turn") + assert ctx.claude_background_followup_pending is True + assert ctx.state == "running" + + asyncio.run(run()) + + def test_background_result_projection_failure_still_settles_lifecycle(): class BrokenProjectionSdk(_AutoCompactSdk): def observe_goal_message(self, message, _thread_id): diff --git a/tests/test_claude_rich_stream.py b/tests/test_claude_rich_stream.py index 2fc3dc2b..6d0228c6 100644 --- a/tests/test_claude_rich_stream.py +++ b/tests/test_claude_rich_stream.py @@ -727,8 +727,8 @@ def test_history_marks_task_completion_followup_as_later_background_segment(): ) assert notification.ts == 20.0 and notification.background is True terminal = next(event for event in events if isinstance(event, TurnEnd)) - assert terminal.ts == 2.0 - assert terminal.result.duration_ms == 1_000 + assert terminal.ts == 21.0 + assert terminal.result.duration_ms == 20_000 assert terminal.turn_id == answer_after diff --git a/tests/test_history.py b/tests/test_history.py index 9976841c..780e4c0f 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -8791,6 +8791,48 @@ def test_late_internal_task_notification_does_not_extend_completed_answer(): assert terminal.result.duration_ms == 10_000 +def test_background_replies_advance_answer_clock_but_late_bookkeeping_does_not(): + ids = [f"{n:08d}-1111-4111-8111-111111111111" for n in range(1, 8)] + prompt, original, first_notice, first_reply, second_notice, final, late = ids + rows = [ + {"uuid": prompt, "type": "user", "message": { + "role": "user", "content": "review"}}, + ] + notices = {} + for uid, is_notice, text in ( + (original, False, "two reviews are running"), + (first_notice, True, "first review completed"), + (first_reply, False, "waiting for the second review"), + (second_notice, True, "second review completed"), + (final, False, "consolidated review"), + (late, True, "old command stopped after resume"), + ): + rows.append({"uuid": uid, "type": "user" if is_notice else "assistant", + "message": {"role": "user" if is_notice else "assistant", + "content": text if is_notice else [{"type": "text", "text": text}], + "stop_reason": None if is_notice else "end_turn"}}) + if is_notice: + notices[uid] = ProcessEvent( + item_id=uid, kind="task", phase="end", status="succeeded", + title=text, background=True) + events = translate_history( + [_session_message(row) for row in rows], 10_000, + timestamps=dict(zip(ids, (1_000, 1_010, 2_000, 2_010, 3_000, 3_010, 40_000))), + internal_user_events=notices) + terminals = [event for event in events if isinstance(event, TurnEnd)] + assert len(terminals) == 1 + assert terminals[0].ts == 3_010 + assert terminals[0].turn_id == final + assert terminals[0].result.duration_ms == 2_010_000 + replies = [event for event in events if isinstance(event, Delta)] + assert [(event.text, event.ts) for event in replies] == [ + ("two reviews are running", 1_010), + ("waiting for the second review", 2_010), + ("consolidated review", 3_010), + ] + assert [event.background for event in replies] == [None, True, True] + + def test_history_hides_cancelled_command_placeholders_without_hiding_real_text(): user_id = "11111111-1111-4111-8111-111111111111" answer_id = "22222222-2222-4222-8222-222222222222" diff --git a/tests/test_history_store.py b/tests/test_history_store.py index b2a38fac..4e00aac8 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -461,7 +461,7 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm connection.execute(f"PRAGMA user_version={old_version}") migrated = HistoryIndexStore(tmp_path / "state") with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='codex'" @@ -499,6 +499,28 @@ def test_narrative_migration_preserves_assets(tmp_path, old_version): assert reopened.get_page("codex", "codex", source, before=None, limit=4) == _page("rebuilt") +@pytest.mark.parametrize("old_version", [38, 39]) +def test_background_clock_migration_only_rebuilds_claude_narrative(tmp_path, old_version): + path = tmp_path / "source.jsonl" + path.write_text("{}\n") + source = HistorySourceFingerprint.capture(path) + store = HistoryIndexStore(tmp_path / "state") + for engine in ("claude", "codex", "dsh"): + store.put_page(engine, engine, source, before=None, limit=4, page=_page(engine)) + store.put_image_asset(engine, engine, source, engine, "image", "thumbnail", "image/png", 1, 1, b"image") + with sqlite3.connect(store.path) as connection: + connection.execute(f"PRAGMA user_version={old_version}") + migrated = HistoryIndexStore(tmp_path / "state") + assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None + for engine in ("codex", "dsh"): + assert migrated.get_page(engine, engine, source, before=None, limit=4) == _page(engine) + with sqlite3.connect(store.path) as connection: + assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 3 + migrated.put_page("claude", "claude", source, before=None, limit=4, page=_page("rebuilt")) + assert HistoryIndexStore(tmp_path / "state").get_page( + "claude", "claude", source, before=None, limit=4) == _page("rebuilt") + + def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path): source_path = tmp_path / "transcript.jsonl" source_path.write_text("{}\n") @@ -518,7 +540,7 @@ def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path): assert migrated.get_page( "session-1", "claude", source, before=None, limit=4) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 assert connection.execute( "SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0 @@ -546,7 +568,7 @@ def test_v20_migration_rebuilds_codex_and_claude_identity_projections(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -607,7 +629,7 @@ def test_v21_migration_rebuilds_claude_alias_and_codex_process_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -664,7 +686,7 @@ def test_v22_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -726,7 +748,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -797,7 +819,7 @@ def test_async_question_migration_preserves_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -906,7 +928,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ( "history_pages", "history_turn_details", @@ -953,7 +975,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1004,7 +1026,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index( "claude-session", "claude", source, before=None, limit=4, ) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -1050,7 +1072,7 @@ def test_recent_migration_invalidates_changed_projection_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -1092,7 +1114,7 @@ def test_owner_and_interrupt_alias_migration_invalidates_both_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -1156,7 +1178,7 @@ def test_recent_summary_migration_rebuilds_pages_but_preserves_source_assets( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 39 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 40 assert connection.execute( "SELECT COUNT(*) FROM history_pages" ).fetchone()[0] == 0 From 0d5c809e67fc0d632b3e2cbd5037d2bb16b151ef Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Wed, 16 Sep 2026 11:27:42 +0800 Subject: [PATCH 05/17] feat(ui): add muted themes and a global bold text preference - Add six muted palettes with per-engine preferences and a responsive theme picker. - Add a persistent bold text toggle that preserves typography hierarchy across dialogs and engines. - Include a static theme preview and desktop, mobile, storage, and contrast regressions. --- web/.gitignore | 1 + web/package.json | 3 +- web/playwright.config.ts | 2 +- web/src/App.css | 60 ++-- web/src/App.tsx | 30 +- web/src/components/BackgroundTaskControl.css | 6 +- web/src/components/CenteredSheet.tsx | 8 +- web/src/components/FileBrowserPanel.css | 2 +- web/src/components/GoalDialog.css | 20 +- web/src/components/HeaderMenu.tsx | 32 +- web/src/components/SpreadsheetPreview.css | 6 +- web/src/components/ThemePicker.css | 69 ++++ web/src/components/ThemePicker.tsx | 54 ++++ web/src/components/remote-viewer.css | 6 +- web/src/components/timed-task.css | 6 +- web/src/icons.tsx | 1 + web/src/index.css | 313 ++++++++++--------- web/src/themes.css | 133 ++++++++ web/src/themes.ts | 91 ++++++ web/src/typography.css | 27 ++ web/src/use-bold-text.ts | 34 ++ web/src/use-theme.ts | 55 ++++ web/tests/history-browser.fixture.tsx | 6 +- web/tests/reliability.test.ts | 2 +- web/tests/remote-viewer-theme.spec.ts | 54 ++++ web/tests/theme-preview.fixture.tsx | 118 +++++++ web/tests/theme-preview.html | 13 + web/tests/theme-preview.md | 40 +++ web/tests/themes.spec.ts | 217 +++++++++++++ web/tests/themes.test.ts | 59 ++++ web/vite.theme-preview.config.ts | 51 +++ 31 files changed, 1291 insertions(+), 228 deletions(-) create mode 100644 web/src/components/ThemePicker.css create mode 100644 web/src/components/ThemePicker.tsx create mode 100644 web/src/themes.css create mode 100644 web/src/themes.ts create mode 100644 web/src/typography.css create mode 100644 web/src/use-bold-text.ts create mode 100644 web/src/use-theme.ts create mode 100644 web/tests/remote-viewer-theme.spec.ts create mode 100644 web/tests/theme-preview.fixture.tsx create mode 100644 web/tests/theme-preview.html create mode 100644 web/tests/theme-preview.md create mode 100644 web/tests/themes.spec.ts create mode 100644 web/tests/themes.test.ts create mode 100644 web/vite.theme-preview.config.ts diff --git a/web/.gitignore b/web/.gitignore index a547bf36..b997c93b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +dist-theme-preview *.local # Editor directories and files diff --git a/web/package.json b/web/package.json index e3429223..4d7df8dd 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "dev": "vite", "build": "tsc -b && vite build && npm run build:viewer-runner && node scripts/check-bundle-budget.mjs", "build:viewer-runner": "vite build --config vite.viewer-runner.config.ts", + "build:theme-preview": "vite build --config vite.theme-preview.config.ts", "lint": "oxlint", "test:compile": "tsc -p tsconfig.tests.json", "test:outbox": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js", @@ -19,7 +20,7 @@ "test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit", "test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js", "test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", - "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", + "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index d5941f9a..43fc82f4 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -25,7 +25,7 @@ const WEBKIT_GENERAL_EXCLUSIONS = [ export default defineConfig({ testDir: "./tests", - testMatch: ["history-browser.spec.ts", "background-tasks.spec.ts"], + testMatch: ["history-browser.spec.ts", "background-tasks.spec.ts", "themes.spec.ts"], fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, diff --git a/web/src/App.css b/web/src/App.css index 76ca64a1..3ab4e2b9 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -2,7 +2,7 @@ font-size: 16px; padding: 5px 10px; border-radius: 5px; - color: var(--accent); + color:var(--accent-text,var(--accent)); background: var(--accent-bg); border: 2px solid transparent; transition: border-color 0.3s; @@ -201,7 +201,7 @@ .goal-chip-loading { cursor:default; } .goal-loading .goal-chip-dot { animation:pulse 1.2s ease-in-out infinite; } .goal-chip-ring { --goal-progress:0deg; position:relative; width:21px; height:21px; - display:grid; place-items:center; flex:none; border-radius:50%; color:var(--accent); + display:grid; place-items:center; flex:none; border-radius:50%; color:var(--accent-text,var(--accent)); background:conic-gradient(currentColor var(--goal-progress),var(--border-strong) 0); } .goal-chip-ring::before { content:""; position:absolute; inset:3px; border-radius:50%; background:var(--surface); } .goal-chip-ring svg { position:relative; } @@ -213,10 +213,10 @@ .goal-chip-dot-paused,.goal-chip-dot-blocked { background:var(--warn); box-shadow:0 0 0 4px var(--warn-weak); } .goal-chip-dot-complete { background:var(--ok); box-shadow:0 0 0 4px var(--ok-weak); } .goal-chip-dot-usageLimited,.goal-chip-dot-budgetLimited { background:var(--danger); box-shadow:0 0 0 4px var(--danger-weak); } -.goal-chip-label { flex:none; color:var(--accent-ink); font-size:11px; font-weight:750; letter-spacing:.02em; } +.goal-chip-label { flex:none; color:var(--accent-ink); font-size:11px; font-weight:var(--font-weight-750); letter-spacing:.02em; } .goal-chip-objective { min-width:0; max-width:min(380px,44vw); overflow:hidden; text-overflow:ellipsis; - white-space:nowrap; font-size:12px; font-weight:600; } -.goal-chip-status { flex:none; color:var(--dim); font-size:10px; font-weight:650; } + white-space:nowrap; font-size:12px; font-weight:var(--font-weight-600); } +.goal-chip-status { flex:none; color:var(--dim); font-size:10px; font-weight:var(--font-weight-650); } .goal-chip-status-paused,.goal-chip-status-blocked { color:var(--warn); } .goal-chip-status-complete { color:var(--ok); } .goal-chip-status-usageLimited,.goal-chip-status-budgetLimited { color:var(--danger); } @@ -227,7 +227,7 @@ .plan-chip-ring.complete { color:var(--ok); } .plan-chip-ring.failed { color:var(--danger); } .plan-chip-ring.stale { color:var(--faint); } -.goal-status { display:inline-flex; align-items:center; width:max-content; border-radius:999px; padding:2px 7px; font-size:10px; line-height:1.4; font-weight:700; color:var(--accent-ink); background:var(--accent-weak); } +.goal-status { display:inline-flex; align-items:center; width:max-content; border-radius:999px; padding:2px 7px; font-size:10px; line-height:1.4; font-weight:var(--font-weight-700); color:var(--accent-ink); background:var(--accent-weak); } .goal-status-paused,.goal-status-blocked { color:var(--warn); background:var(--warn-weak); } .goal-status-complete { color:var(--ok); background:var(--ok-weak); } .goal-status-usageLimited,.goal-status-budgetLimited { color:var(--danger); background:var(--danger-weak); } @@ -236,7 +236,7 @@ .rollback-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); } .rollback-head-icon { width:38px; height:38px; display:grid; place-items:center; - border-radius:12px; color:#fff; background:var(--accent); } + border-radius:12px; color:var(--on-accent,#fff); background:var(--accent); } .rollback-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; } .rollback-head b { font-size:15px; } .rollback-head small { color:var(--faint); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -248,7 +248,7 @@ border:1px solid var(--border); border-radius:12px; background:var(--surface); } .rollback-options > button:hover { border-color:var(--accent-line); background:var(--accent-weak); } .rollback-options > button > span:first-child { width:34px; height:34px; display:grid; - place-items:center; border-radius:10px; color:var(--accent); background:var(--raised); } + place-items:center; border-radius:10px; color:var(--accent-text,var(--accent)); background:var(--raised); } .rollback-options > button > span:nth-child(2) { min-width:0; display:flex; flex-direction:column; gap:2px; } .rollback-options b { font-size:13px; } .rollback-options small { color:var(--dim); font-size:11px; line-height:1.45; } @@ -279,12 +279,12 @@ } .qa-text-answer { display: flex; gap: 8px; margin-top: 12px; } .qa-text-answer input { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: 9px; padding: 10px; background: var(--surface, #fff); color: inherit; } -.qa-text-answer button { border: 0; border-radius: 9px; padding: 0 14px; background: var(--accent, #5b6ee1); color: white; } +.qa-text-answer button { border: 0; border-radius: 9px; padding: 0 14px; background: var(--accent, #5b6ee1); color:var(--on-accent,#fff); } .qa-text-answer button:disabled { opacity: .45; } .status-sheet { padding:0; } .status-sheet-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; } -.status-sheet-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:#fff; } +.status-sheet-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:var(--on-accent,#fff); } .status-sheet-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; } .status-sheet-head b { font-size:15px; } .status-sheet-head small { color:var(--faint); font-size:11px; } @@ -300,7 +300,7 @@ .status-grid { display:grid; grid-template-columns:1fr 1fr; gap:1px 18px; } .status-row { min-width:0; display:flex; justify-content:space-between; align-items:baseline; gap:10px; padding:6px 0; border-bottom:1px dashed color-mix(in srgb,var(--divider) 72%,transparent); } .status-row > span { flex:none; color:var(--dim); font-size:11.5px; } -.status-row > b { min-width:0; overflow:hidden; text-overflow:ellipsis; text-align:right; color:var(--text); font-size:11.5px; font-weight:650; } +.status-row > b { min-width:0; overflow:hidden; text-overflow:ellipsis; text-align:right; color:var(--text); font-size:11.5px; font-weight:var(--font-weight-650); } .status-row > b.mono { font-family:var(--mono); font-size:10.5px; } .status-capability-note { margin-top:9px; padding:8px 10px; border:1px solid var(--border); border-radius:9px; color:var(--dim); background:var(--raised); font-size:10.5px; line-height:1.5; } .status-thread-state { display:inline-flex; align-items:center; padding:2px 7px; border-radius:999px; background:var(--raised); } @@ -335,7 +335,7 @@ .status-reset-credit > span { min-width:0; flex:1; display:flex; flex-direction:column; gap:2px; } .status-reset-credit b { font-size:11.5px; } .status-reset-credit small { color:var(--dim); font-size:9.5px; line-height:1.4; overflow-wrap:anywhere; } -.status-reset-credit button,.status-reset-next { flex:none; padding:6px 10px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-weight:650; } +.status-reset-credit button,.status-reset-next { flex:none; padding:6px 10px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-weight:var(--font-weight-650); } .status-reset-credit button:hover,.status-reset-next:hover { filter:brightness(1.08); } .status-reset-credit button:disabled,.status-reset-next:disabled { opacity:.45; cursor:default; filter:none; } .status-reset-credit em { flex:none; color:var(--faint); font-size:10px; font-style:normal; } @@ -359,11 +359,11 @@ 53-week contribution calendar, using cc-remote's Codex accent tokens. */ .usage-activity-sheet { padding:0; max-height:min(88dvh,calc(var(--app-height,100dvh) - 12px)); } .usage-activity-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; } -.usage-activity-icon { width:38px; height:38px; display:grid; place-items:center; flex:none; border-radius:11px; color:#fff; background:var(--accent); box-shadow:0 7px 20px -13px var(--accent); } +.usage-activity-icon { width:38px; height:38px; display:grid; place-items:center; flex:none; border-radius:11px; color:var(--on-accent,#fff); background:var(--accent); box-shadow:0 7px 20px -13px var(--accent); } .usage-activity-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; } .usage-activity-head b { font-size:15px; } .usage-activity-head small { overflow:hidden; color:var(--faint); font-size:11px; text-overflow:ellipsis; white-space:nowrap; } -.usage-activity-head > em { padding:3px 8px; border:1px solid var(--accent-line); border-radius:999px; color:var(--accent-ink); background:var(--accent-weak); font:600 10px/1.4 var(--mono); text-transform:capitalize; } +.usage-activity-head > em { padding:3px 8px; border:1px solid var(--accent-line); border-radius:999px; color:var(--accent-ink); background:var(--accent-weak); font:var(--font-weight-600) 10px/1.4 var(--mono); text-transform:capitalize; } .usage-activity-refresh,.usage-activity-close { min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border-radius:9px; color:var(--dim); } .usage-activity-refresh { padding:0 9px; border:1px solid var(--border); font-size:11px; } .usage-activity-close { width:34px; } @@ -373,14 +373,14 @@ .usage-activity-stats { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); overflow:hidden; margin-bottom:22px; border:1px solid var(--border); border-radius:13px; background:color-mix(in srgb,var(--surface) 82%,var(--raised)); box-shadow:var(--shadow-sm); } .usage-activity-stats > div { min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:13px 8px 12px; position:relative; text-align:center; } .usage-activity-stats > div + div::before { position:absolute; top:13px; bottom:13px; left:0; width:1px; background:var(--divider); content:""; } -.usage-activity-stats b { max-width:100%; overflow:hidden; font:650 13px/1.35 var(--mono); text-overflow:ellipsis; white-space:nowrap; } +.usage-activity-stats b { max-width:100%; overflow:hidden; font:var(--font-weight-650) 13px/1.35 var(--mono); text-overflow:ellipsis; white-space:nowrap; } .usage-activity-stats span { color:var(--dim); font-size:10.5px; white-space:nowrap; } .usage-activity-panel { padding:2px 0 0; } .usage-activity-panel > header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom:10px; } .usage-activity-panel > header > span { display:flex; flex-direction:column; } .usage-activity-panel > header b { font-size:13px; } .usage-activity-panel > header small { color:var(--faint); font-size:10.5px; } -.usage-activity-panel > header em { padding:4px 9px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-style:normal; font-weight:650; } +.usage-activity-panel > header em { padding:4px 9px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-style:normal; font-weight:var(--font-weight-650); } .usage-activity-viewport { max-width:100%; overflow-x:auto; padding:6px 1px 5px; overscroll-behavior-inline:contain; scrollbar-color:var(--border-strong) transparent; scrollbar-width:thin; } .usage-activity-calendar { --usage-tile-size:11px; --usage-tile-gap:3px; width:max-content; min-width:100%; } .usage-activity-grid { display:grid; width:max-content; grid-auto-flow:column; grid-template-rows:repeat(7,var(--usage-tile-size)); grid-auto-columns:var(--usage-tile-size); gap:var(--usage-tile-gap); } @@ -402,7 +402,7 @@ .usage-activity-legend i { width:10px; height:10px; display:block; } .usage-activity-empty { min-height:150px; display:grid; place-items:center; border:1px dashed var(--border-strong); border-radius:12px; color:var(--dim); background:var(--bg); font-size:11.5px; } .usage-activity-state { min-height:300px; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:var(--dim); text-align:center; } -.usage-activity-state svg { color:var(--accent); } +.usage-activity-state svg { color:var(--accent-text,var(--accent)); } .usage-activity-state b { color:var(--text); font-size:13px; } .usage-activity-state span { max-width:340px; font-size:11px; line-height:1.5; } .usage-activity-spinner { width:18px; height:18px; border:2px solid var(--border-strong); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; } @@ -427,7 +427,7 @@ .fork-worktree-sheet { padding:0; } .fork-worktree-sheet > form { min-height:0; display:flex; flex:1; flex-direction:column; } .fork-worktree-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; } -.fork-worktree-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:#fff; } +.fork-worktree-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:var(--on-accent,#fff); } .fork-worktree-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; } .fork-worktree-head b { font-size:15px; } .fork-worktree-head small { color:var(--faint); font-size:11px; } @@ -441,17 +441,17 @@ .fork-worktree-source { display:grid; grid-template-columns:auto minmax(0,1fr); gap:7px 12px; align-items:baseline; padding:11px 12px; border:1px solid var(--border); border-radius:12px; background:var(--bg); } .fork-worktree-source > span { color:var(--faint); font-size:10.5px; } .fork-worktree-source > code { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font-family:var(--mono); font-size:10.5px; text-align:right; } -.fork-worktree-field { display:flex; flex-direction:column; gap:6px; color:var(--dim); font-size:11px; font-weight:650; } +.fork-worktree-field { display:flex; flex-direction:column; gap:6px; color:var(--dim); font-size:11px; font-weight:var(--font-weight-650); } .fork-worktree-field input { width:100%; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:11px; padding:11px 12px; background:var(--bg); color:var(--text); outline:none; font:inherit; font-size:16px; } .fork-worktree-field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-weak); } .fork-worktree-field input:disabled { opacity:.62; } -.fork-worktree-field small { color:var(--faint); font-size:10.5px; font-weight:500; } +.fork-worktree-field small { color:var(--faint); font-size:10.5px; font-weight:var(--font-weight-500); } .fork-worktree-error { padding:9px 11px; border-radius:10px; color:var(--danger); background:var(--danger-weak); font-size:11.5px; line-height:1.45; white-space:pre-wrap; overflow-wrap:anywhere; } .fork-worktree-actions { display:flex; justify-content:flex-end; gap:8px; padding:12px 18px calc(12px + env(safe-area-inset-bottom)); border-top:1px solid var(--divider); flex:none; } -.fork-worktree-actions button { min-height:40px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:8px 14px; border-radius:10px; font-size:13px; font-weight:650; } +.fork-worktree-actions button { min-height:40px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:8px 14px; border-radius:10px; font-size:13px; font-weight:var(--font-weight-650); } .fork-worktree-actions button:disabled { opacity:.44; cursor:not-allowed; } .fork-worktree-cancel { color:var(--dim); background:var(--raised); } -.fork-worktree-primary { min-width:150px; color:#fff; background:var(--accent); } +.fork-worktree-primary { min-width:150px; color:var(--on-accent,#fff); background:var(--accent); } .fork-worktree-spinner { width:13px; height:13px; border:2px solid rgba(255,255,255,.48); border-top-color:#fff; border-radius:50%; animation:spin .7s linear infinite; } @media (max-width:700px) { .status-sheet-scroll { padding-left:14px; padding-right:14px; } @@ -482,7 +482,7 @@ background: transparent; color: var(--dim); font-size: .9em; - font-weight: 500; + font-weight: var(--font-weight-500); line-height: 1.5; overflow-wrap: anywhere; list-style: none; @@ -551,7 +551,7 @@ .generated-output figcaption { display: flex; align-items: center; gap: 7px; margin-top: 8px; color: var(--dim); font-size: 12px; } .generated-image-size { color: var(--faint); font-size: 11px; } .generated-image-open { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; padding: 5px 0 5px 10px; border: 0; background: none; color: var(--dim); font: inherit; cursor: pointer; } -.generated-image-open:hover { color: var(--accent); } +.generated-image-open:hover { color:var(--accent-text,var(--accent)); } .async-question-card { display: flex; align-items: center; gap: 12px; width: min(100%, 480px); @@ -574,7 +574,7 @@ max-height: min(720px, calc(var(--app-height, 100dvh) - 32px)); border: 1px solid var(--border); border-radius: 36px; box-shadow: var(--shadow-lg); background: var(--surface); color: var(--text); overflow: hidden; - font: 15px/1.6 var(--sans); overflow-wrap: anywhere; + font: var(--font-weight-400) 15px/1.6 var(--sans); overflow-wrap: anywhere; } .async-question-dialog[open] { display: flex; flex-direction: column; } .async-question-dialog::backdrop { background: rgb(18 28 48 / .2); } @@ -588,7 +588,7 @@ .async-question-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 28px 20px; scrollbar-width: thin; } .async-question-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } .async-question-body fieldset + fieldset { margin-top: 24px; } -.async-question-body legend { width: 100%; padding: 0; margin-bottom: 18px; font-size: 18px; font-weight: 500; line-height: 1.65; white-space: pre-wrap; } +.async-question-body legend { width: 100%; padding: 0; margin-bottom: 18px; font-size: 18px; font-weight: var(--font-weight-500); line-height: 1.65; white-space: pre-wrap; } .async-question-option { position: relative; display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 10px 20px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 999px; cursor: pointer; font-size: 14px; } /* A transparent radio still gets a native tap overlay. Suppress it on both hit targets; keyboard focus remains visible on the rounded option below. */ @@ -599,7 +599,7 @@ .async-question-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; } .async-question-option > span { pointer-events: none; } .async-question-option > span:first-of-type { flex: 1; min-width: 0; white-space: pre-wrap; } -.async-question-option-check { display: flex; flex: none; color: var(--accent); visibility: hidden; } +.async-question-option-check { display: flex; flex: none; color:var(--accent-text,var(--accent)); visibility: hidden; } .async-question-option:has(input:checked) .async-question-option-check { visibility: visible; } .async-question-label-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .async-question-composer { margin-top: 12px; padding: 14px 18px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); } @@ -612,7 +612,7 @@ .async-question-footer-actions { display: flex; gap: 8px; margin-left: auto; flex: none; } .async-question-send, .async-question-later { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 18px; border: 0; border-radius: 999px; font: inherit; font-size: 13px; cursor: pointer; } .async-question-later { background: var(--raised); color: var(--dim); } -.async-question-send { background: var(--accent); color: white; } +.async-question-send { background: var(--accent); color:var(--on-accent,#fff); } :root[data-theme="dark"] .async-question-send { color: var(--bg); } .async-question-send > svg { transform: rotate(90deg); } .async-question-send:disabled { opacity: .4; cursor: default; } @@ -640,7 +640,7 @@ .engine-selector { display: inline-flex; position: relative; flex: none; } .engine-selector .engine-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px; - min-height: 32px; border-radius: 9px; font: 600 11px var(--mono); letter-spacing: .02em; cursor: pointer; } + min-height: 32px; border-radius: 9px; font: var(--font-weight-600) 11px var(--mono); letter-spacing: .02em; cursor: pointer; } .engine-label { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } .engine-label svg { flex: none; } .engine-toggle > svg { color: var(--dim); transition: transform 120ms ease; } @@ -650,7 +650,7 @@ border-radius: 13px; background: var(--surface); color: var(--text); box-shadow: 0 8px 30px #0002; } .engine-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--dim); - font: 500 13px var(--sans); text-align: left; cursor: pointer; } + font: var(--font-weight-500) 13px var(--sans); text-align: left; cursor: pointer; } .engine-menu-item span { flex: 1; color: var(--text); } .engine-menu-item[aria-checked="true"] { background: var(--accent-weak); color: var(--accent-ink); } .engine-menu-item[aria-checked="true"] span { color: inherit; } diff --git a/web/src/App.tsx b/web/src/App.tsx index ce86e0b2..d87c0e00 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -50,7 +50,6 @@ import { WorkDashboardSheet } from "./components/WorkDashboardSheet"; import type { HookDraft, SkillDraft } from "./components/CapabilitiesSheet"; import { TerminalControl } from "./components/TerminalControl"; import { DeviceSheet, type PairingState, type RemoteDevice } from "./components/DeviceSheet"; -import { HeaderMenu } from "./components/HeaderMenu"; import { EngineSelector } from "./components/EngineSelector"; import { claudeProfileIdForSession, @@ -114,11 +113,11 @@ import { withoutForkFocusPlaceholder, } from "./session-worktree"; import { matchesBtwRequest, - normalizeDiffTheme, normalizeEngine, type Snapshot, type QueryImg, + normalizeEngine, type Snapshot, type QueryImg, type QueryFile, type SessionInfo, type CodexPermissionMode, type CodexWebSearchMode, type PermissionProfileInfo, type CodexServiceTier, type CollaborationModeName, - type DiffTheme, type Engine, type Space, + type Engine, type Space, type SessionControl, type History, sessionControlLocksInput } from "./protocol"; import type { EngineCapabilities, EngineCapabilityItem, EngineCapabilityKind, WorkArtifactInfo, WorkDashboard } from "./protocol"; @@ -253,8 +252,13 @@ import { import type { AgentDetail, FilesListed } from "./protocol"; import type { AgentDetailSelection } from "./components/AgentDetailController"; import type { RightPanelView } from "./components/PanelTabs"; +import { useTheme } from "./use-theme"; +import { useBoldText } from "./use-bold-text"; + +const HeaderMenu = lazy(() => import("./components/HeaderMenu").then( + ({ HeaderMenu: Menu }) => ({ default: Menu }), +)); -const THEME_KEY = "cc_remote_theme"; const ENGINE_KEY = "cc_remote_engine"; // which backend the NEXT new session uses const MACHINE_KEY = "cc_remote_machine"; const GoalPanel = lazy(() => import("./components/GoalPanel").then( @@ -327,11 +331,11 @@ function catalogForEngineProfile( } export default function App() { - const [theme, setTheme] = useState( - () => normalizeDiffTheme(localStorage.getItem(THEME_KEY))); const initialEngineRef = useRef(normalizeEngine(localStorage.getItem(ENGINE_KEY))); const initialSpacesRef = useRef(readEngineSpaces(localStorage, initialEngineRef.current)); const [engine, setEngine] = useState(initialEngineRef.current); + const { mode: theme, choice: themeChoice, selectTheme } = useTheme(engine); + const { boldText, setBoldText } = useBoldText(); const [space, setSpace] = useState(initialSpacesRef.current[initialEngineRef.current]); const spacesByEngineRef = useRef>(initialSpacesRef.current); const [authed, setAuthed] = useState(false); @@ -1628,17 +1632,13 @@ export default function App() { else if (action === "close") setSidebarOpen(false); }; - useEffect(() => { - document.documentElement.setAttribute("data-theme", theme); - localStorage.setItem(THEME_KEY, theme); - }, [theme]); useEffect(() => { try { sessionStorage.setItem( BTW_PANEL_SCOPES_KEY, JSON.stringify(btwPanelScopes)); } catch { /* storage is best-effort in private browsing */ } }, [btwPanelScopes]); - const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); + const toggleTheme = () => selectTheme(theme === "dark" ? "light" : "dark"); // `engine` selects the backend (Claude Code / Codex): the whole UI re-skins via // data-engine, and the sidebar re-lists that engine's own sessions. @@ -5672,9 +5672,11 @@ export default function App() { {activeDevice?.label ?? machineId} -
strong { font-size:14px; font-weight:650; } +.background-task-header > strong { font-size:14px; font-weight:var(--font-weight-650); } .background-task-header > span { padding:2px 7px; border-radius:999px; background:var(--raised); color:var(--dim); font-size:11px; } @@ -51,7 +51,7 @@ .background-task-list.preview > li { border:0; padding:0; background:none; } .background-task-list.preview strong { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; - overflow:hidden; font-size:12px; font-weight:600; + overflow:hidden; font-size:12px; font-weight:var(--font-weight-600); } .background-task-more { color:var(--dim); font-size:11px; } .background-task-sheet-content { display:flex; flex-direction:column; min-height:0; max-height:inherit; } diff --git a/web/src/components/CenteredSheet.tsx b/web/src/components/CenteredSheet.tsx index 041e639a..abb0bb65 100644 --- a/web/src/components/CenteredSheet.tsx +++ b/web/src/components/CenteredSheet.tsx @@ -6,7 +6,7 @@ import "./CenteredSheet.css"; /** Shared selection surface, centered in the visible chat on every device. */ export function CenteredSheet({ open, label, onClose, children, className = "", - maxWidth = 520, maxHeight = 720, header = true, returnFocusRef }: { + maxWidth = 520, maxHeight = 720, minimumHeight = 360, header = true, returnFocusRef }: { open: boolean; label: string; onClose: () => void; @@ -14,6 +14,8 @@ export function CenteredSheet({ open, label, onClose, children, className = "", className?: string; maxWidth?: number; maxHeight?: number; + /** Allow content-heavy pickers to use the visible viewport when chat space is short. */ + minimumHeight?: number; header?: boolean; /** Touch browsers may leave the trigger unfocused when opening a dialog. */ returnFocusRef?: RefObject; @@ -22,7 +24,7 @@ export function CenteredSheet({ open, label, onClose, children, className = "", const closeRef = useRef(onClose); closeRef.current = onClose; const geometry = useChatDialogGeometry({ - open, maxWidth, maxHeight, minimumHeight: 360, + open, maxWidth, maxHeight, minimumHeight, }); const visible = open && !!geometry; useEffect(() => { @@ -58,7 +60,7 @@ export function CenteredSheet({ open, label, onClose, children, className = "", const content = <>
+ role="dialog" aria-modal="true" aria-label={label} style={geometry ?? undefined} data-lock-horizontal-swipe> {header &&
{label} +
, document.body, )} + {themeOpen && + setThemeOpen(false)} returnFocusRef={triggerRef} /> + } ); } diff --git a/web/src/components/SpreadsheetPreview.css b/web/src/components/SpreadsheetPreview.css index 670c8dbd..4f64e842 100644 --- a/web/src/components/SpreadsheetPreview.css +++ b/web/src/components/SpreadsheetPreview.css @@ -7,9 +7,9 @@ .spreadsheet-scroll{overflow:auto;flex:1;min-height:100px;overscroll-behavior:contain;border-block:1px solid var(--border);background:var(--surface)} .spreadsheet-scroll table{border-collapse:separate;border-spacing:0;min-width:100%;font-size:13px;margin:0} .spreadsheet-scroll td,.spreadsheet-scroll th{border-right:1px solid var(--border);border-bottom:1px solid var(--border);padding:7px 10px;min-width:100px;max-width:350px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:left;vertical-align:top} -.spreadsheet-scroll thead th{position:sticky;top:0;background:var(--raised);text-align:center;color:var(--dim);font-weight:500;z-index:2} -.spreadsheet-scroll tbody th,.spreadsheet-scroll thead th:first-child{position:sticky;left:0;background:var(--raised);color:var(--dim);font-weight:400;min-width:44px;text-align:right;z-index:1} +.spreadsheet-scroll thead th{position:sticky;top:0;background:var(--raised);text-align:center;color:var(--dim);font-weight:var(--font-weight-500);z-index:2} +.spreadsheet-scroll tbody th,.spreadsheet-scroll thead th:first-child{position:sticky;left:0;background:var(--raised);color:var(--dim);font-weight:var(--font-weight-400);min-width:44px;text-align:right;z-index:1} .spreadsheet-scroll thead th:first-child{z-index:3} .spreadsheet-note{margin:0;padding:10px 16px;color:var(--dim);font-size:12px;line-height:1.5} -.spreadsheet-note strong{font-weight:500;color:var(--accent-ink)} +.spreadsheet-note strong{font-weight:var(--font-weight-500);color:var(--accent-ink)} .spreadsheet-empty{padding:30px;text-align:center;color:var(--dim)} diff --git a/web/src/components/ThemePicker.css b/web/src/components/ThemePicker.css new file mode 100644 index 00000000..9b09091e --- /dev/null +++ b/web/src/components/ThemePicker.css @@ -0,0 +1,69 @@ +.theme-picker-intro { display:flex; align-items:center; flex-wrap:wrap; gap:9px; padding:0 20px 18px; } +.theme-picker-intro > span { font-size:11px; font-weight:var(--font-weight-600); padding:3px 9px; border-radius:999px; + color:var(--accent-ink); background:var(--accent-weak); } +.theme-picker-intro p { margin:0; font-size:12px; color:var(--dim); } +.theme-picker .theme-picker-scroll { padding:0 20px 20px; } +.theme-basics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.theme-basics button { display:flex; align-items:center; justify-content:center; gap:7px; min-height:44px; + padding:8px; font-size:12px; border:1px solid var(--border); border-radius:13px; background:var(--raised); color:var(--dim); } +.theme-basics button[aria-pressed="true"] { color:var(--accent-ink); background:var(--accent-weak); border-color:var(--accent-line); } +.theme-basics button > svg { flex:none; } +.theme-section-label { display:flex; align-items:center; justify-content:space-between; gap:12px; + margin:22px 0 12px; font-size:12px; font-weight:var(--font-weight-600); } +.theme-section-label span { font-size:11px; font-weight:var(--font-weight-400); color:var(--dim); } +.theme-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; } +.theme-card { display:flex; flex-direction:column; min-width:0; text-align:left; border:1px solid var(--border); + border-radius:17px; padding:7px 7px 11px; background:var(--bg); transition:border-color .16s,box-shadow .16s; } +.theme-card:hover { border-color:var(--border-strong); box-shadow:var(--shadow-sm); } +.theme-card[aria-pressed="true"] { border-color:var(--accent-ink); box-shadow:0 0 0 1px var(--accent-ink); } +.theme-miniature { position:relative; display:flex; width:100%; aspect-ratio:1.68; overflow:hidden; + border:1px solid var(--border); border-radius:11px; background:var(--bg); } +.theme-mini-sidebar { width:27%; flex:none; display:flex; flex-direction:column; gap:8px; padding:13px 6px; + background:var(--sidebar); border-right:1px solid var(--border); } +.theme-mini-sidebar i { height:3px; width:75%; border-radius:3px; background:var(--dim); opacity:.35; } +.theme-mini-sidebar b { height:10px; border-radius:4px; background:var(--accent-weak); border:1px solid var(--accent-line); } +.theme-mini-chat { display:flex; flex:1; min-width:0; flex-direction:column; gap:8px; padding:9px 8px 7px; } +.theme-mini-head { display:flex; justify-content:space-between; align-items:center; } +.theme-mini-head i { height:3px; width:28%; border-radius:2px; background:var(--dim); opacity:.6; } +.theme-mini-head b { height:5px; width:5px; border-radius:50%; background:var(--accent); } +.theme-mini-bubble { display:block; align-self:flex-end; width:61%; height:12px; border-radius:5px; + background:var(--accent-weak); border:1px solid var(--accent-line); } +.theme-mini-lines { display:flex; flex-direction:column; gap:4px; } +.theme-mini-lines i { height:2px; width:87%; border-radius:2px; background:var(--text); opacity:.22; } +.theme-mini-lines i:nth-child(2) { width:73%; } +.theme-mini-lines i:last-child { width:42%; } +.theme-mini-input { display:flex; align-items:center; justify-content:space-between; min-height:13px; padding:3px 4px; + margin-top:auto; border:1px solid var(--border); border-radius:6px; background:var(--surface); } +.theme-mini-input i { width:40%; height:2px; background:var(--dim); opacity:.35; } +.theme-mini-input b { width:6px; height:6px; border-radius:50%; background:var(--accent); } +.theme-selected { position:absolute; top:5px; right:5px; width:22px; height:22px; display:grid; place-items:center; + border-radius:50%; background:var(--accent); color:var(--on-accent); box-shadow:0 1px 5px rgb(0 0 0 / .15); } +.theme-card-name { display:flex; align-items:center; justify-content:space-between; gap:5px; padding:9px 4px 0; font-size:12px; font-weight:var(--font-weight-600); } +.theme-card-name > span { font-size:10px; font-weight:var(--font-weight-400); color:var(--dim); } +.theme-card-description { padding:3px 4px 0; font-size:10.5px; color:var(--dim); } +.theme-picker-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; flex:none; + padding:12px 20px; border-top:1px solid var(--border); } +.theme-picker-footer > span { color:var(--dim); font-size:12px; } +.theme-picker-footer > button { padding:8px 22px; min-height:38px; border-radius:999px; + background:var(--accent); color:var(--on-accent,#fff); font-size:12px; font-weight:var(--font-weight-600); } +@media (max-width:600px) { + .theme-picker-intro { padding:0 14px 14px; gap:6px; } + .theme-picker-intro p { font-size:11px; } + .theme-picker .theme-picker-scroll { padding:0 14px 14px; } + .theme-basics { gap:5px; } + .theme-basics button { gap:4px; padding:7px 4px; font-size:11px; } + .theme-basics button > svg { width:15px; } + .theme-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } + .theme-section-label { margin-top:17px; } + .theme-card { border-radius:15px; padding-bottom:9px; } + .theme-miniature { height:66px; aspect-ratio:auto; } + .theme-mini-sidebar { gap:5px; padding:8px 6px; } + .theme-mini-chat { gap:5px; padding:6px; } + .theme-mini-lines { gap:3px; } + .theme-mini-bubble { height:9px; } + .theme-mini-input { min-height:11px; padding:2px 4px; } + .theme-picker-footer { padding:10px 14px max(10px,env(safe-area-inset-bottom)); } +} +@media (prefers-reduced-motion:reduce) { + .theme-card,.theme-basics button { transition:none; } +} diff --git a/web/src/components/ThemePicker.tsx b/web/src/components/ThemePicker.tsx new file mode 100644 index 00000000..5061f644 --- /dev/null +++ b/web/src/components/ThemePicker.tsx @@ -0,0 +1,54 @@ +import type { RefObject } from "react"; +import { Icon } from "../icons"; +import { THEME_PALETTES, themeLabel, type ThemeChoice, type ThemeEngine } from "../themes"; +import { CenteredSheet } from "./CenteredSheet"; +import "./ThemePicker.css"; + +export default function ThemePicker({ engine, choice, onSelect, onClose, returnFocusRef }: { + engine: ThemeEngine; + choice: ThemeChoice; + onSelect: (choice: ThemeChoice) => void; + onClose: () => void; + returnFocusRef?: RefObject; +}) { + return +
+ {({ claude: "Claude", codex: "Codex", dsh: "DSH" })[engine]} +

点击即切换,为当前引擎单独记住。

+
+
+
+ {(["system", "light", "dark"] as const).map(id => )} +
+
柔和配色 低饱和 · 轻灰调
+
+ {THEME_PALETTES.map(palette => )} +
+
+
+ 已选:{themeLabel(choice)} + +
+
; +} diff --git a/web/src/components/remote-viewer.css b/web/src/components/remote-viewer.css index d936ce7c..c7ce2306 100644 --- a/web/src/components/remote-viewer.css +++ b/web/src/components/remote-viewer.css @@ -1,7 +1,7 @@ .remote-viewer-panel{padding:0;min-width:0;isolation:isolate} .viewer-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);flex:none;color:var(--dim)} .viewer-heading{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px} -.viewer-heading strong{color:var(--text);font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.viewer-heading strong{color:var(--text);font-size:14px;font-weight:var(--font-weight-600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .viewer-heading small{font-size:11px;color:var(--dim)} .viewer-header .iconbtn{flex:none;border-radius:9px} .viewer-location{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--raised);border-bottom:1px solid var(--border);font-size:11px;color:var(--dim)} @@ -15,7 +15,7 @@ .viewer-stage{position:relative;display:flex;flex:1;min-height:0;min-width:0;background:var(--surface)} .viewer-stage>iframe{display:block;border:0;flex:1;width:100%;height:100%;min-height:0;min-width:0;background:#f8fafc} .viewer-empty{display:flex;flex:1;min-width:0;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:32px 24px;color:var(--dim);text-align:center} -.viewer-empty>b{font-size:14px;color:var(--text);font-weight:500} +.viewer-empty>b{font-size:14px;color:var(--text);font-weight:var(--font-weight-500)} .viewer-empty p{margin:0;max-width:30em;font-size:13px;line-height:1.7} .viewer-action{margin-top:4px;background:var(--accent-weak);border:1px solid var(--border);border-radius:10px;padding:8px 14px;color:var(--accent-ink);font-size:12px;cursor:pointer} .viewer-catalog{width:100%;padding:18px;overflow:auto} @@ -24,7 +24,7 @@ .viewer-site:hover{background:var(--raised);border-color:var(--border-strong)} .viewer-site-icon{display:grid;place-items:center;width:36px;height:36px;flex:none;border-radius:11px;background:var(--accent-weak);color:var(--accent-ink)} .viewer-site>span:nth-child(2){flex:1;display:flex;min-width:0;flex-direction:column;gap:5px} -.viewer-site b{font-size:13px;font-weight:550;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.viewer-site b{font-size:13px;font-weight:var(--font-weight-550);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .viewer-site small{font-size:11px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .viewer-loading{position:absolute;left:50%;top:16px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;background:var(--surface);padding:8px 12px;border:1px solid var(--border);border-radius:12px;font-size:12px;color:var(--dim);box-shadow:var(--shadow-sm)} .viewer-mobile-back{display:none} diff --git a/web/src/components/timed-task.css b/web/src/components/timed-task.css index 1ec29005..8aa42adf 100644 --- a/web/src/components/timed-task.css +++ b/web/src/components/timed-task.css @@ -29,11 +29,11 @@ border-radius: 20px; box-shadow: var(--shadow); background: var(--surface); color: var(--text); font-size: 13px; line-height: 1.5; } -.timed-task-heading { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--accent-ink); } +.timed-task-heading { display: flex; align-items: center; gap: 8px; font-weight: var(--font-weight-600); color: var(--accent-ink); } .timed-task-title { color: var(--dim); margin-top: 9px; overflow-wrap: anywhere; } .timed-task-title span { color: var(--faint); } .timed-task-next { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-top: 17px; color: var(--dim); } -.timed-task-next strong { font-family: var(--mono); font-size: 19px; font-weight: 500; color: var(--text); } +.timed-task-next strong { font-family: var(--mono); font-size: 19px; font-weight: var(--font-weight-500); color: var(--text); } .timed-task-countdown { text-align: right; color: var(--accent-ink); font-variant-numeric: tabular-nums; } .timed-task-progress { border-top: 1px solid var(--border); margin-top: 14px; padding-top: 11px; color: var(--dim); font-size: 12px; } .timed-message-tag { font-size: 12px; line-height: 1.5; color: var(--dim); margin-bottom: 8px; white-space: normal; } @@ -42,4 +42,4 @@ .timed-message-tag summary:hover { color: var(--accent-ink); } .timed-message-tag[open] summary svg:last-child { transform: rotate(180deg); } .timed-message-tag > div { display: flex; flex-direction: column; gap: 3px; border-top: 1px solid var(--accent-line); margin-top: 8px; padding-top: 8px; } -.timed-message-tag strong { font-weight: 500; color: var(--text); } +.timed-message-tag strong { font-weight: var(--font-weight-500); color: var(--text); } diff --git a/web/src/icons.tsx b/web/src/icons.tsx index 7f0b98a6..1f8c90d1 100644 --- a/web/src/icons.tsx +++ b/web/src/icons.tsx @@ -14,6 +14,7 @@ const PATHS: Record = { image: '', camera: '', back: '', + bold: '', dots: '', send: '', stop: '', diff --git a/web/src/index.css b/web/src/index.css index 4a766476..4bda1f2e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,3 +1,6 @@ +@import "./themes.css"; +@import "./typography.css"; + /* cc-remote — warm paper theme (absorbed from design/prototype.html) */ *,*::before,*::after{box-sizing:border-box} html,body,#root{height:100%;margin:0;overflow:hidden} @@ -106,14 +109,14 @@ body{ .iconbtn svg{ width:20px; height:20px; } svg{ display:block; } /* engine selector (which backend the next NEW session uses) */ -.engine-toggle{ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.02em; +.engine-toggle{ font-family:var(--mono); font-size:11px; font-weight:var(--font-weight-600); letter-spacing:.02em; padding:6px 10px; border-radius:9px; flex:none; transition:.16s; white-space:nowrap; color:var(--dim); background:var(--raised); border:1px solid var(--border-strong); } .engine-toggle:hover{ color:var(--accent-ink); border-color:var(--accent-line); } -:root[data-engine="codex"] .engine-toggle{ color:var(--accent); background:var(--accent-weak); border-color:var(--accent-line); } -.newchat-engine{ display:inline-block; margin-left:8px; font-family:var(--mono); font-size:11px; font-weight:600; +:root[data-engine="codex"] .engine-toggle{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); border-color:var(--accent-line); } +.newchat-engine{ display:inline-block; margin-left:8px; font-family:var(--mono); font-size:11px; font-weight:var(--font-weight-600); padding:3px 8px; border-radius:8px; vertical-align:middle; border:1px solid var(--border-strong); color:var(--dim); background:var(--raised); } -.newchat-engine.codex{ color:var(--accent); background:var(--accent-weak); border-color:var(--accent-line); } +.newchat-engine.codex{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); border-color:var(--accent-line); } /* CHAT HEADER */ .c-head{ display:flex; align-items:center; gap:6px; padding:9px 10px 9px 6px; @@ -124,12 +127,12 @@ body{ .c-head::after{ content:""; position:absolute; left:0; right:0; bottom:0; height:1px; z-index:0; pointer-events:none; background:linear-gradient(to right, transparent 0%, var(--divider) 12%, var(--divider) 88%, transparent 100%); } .c-head .titlewrap{ flex:1; min-width:0; } -.c-head .ttl{ font-weight:600; font-size:15px; letter-spacing:-.01em; +.c-head .ttl{ font-weight:var(--font-weight-600); font-size:15px; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:flex; align-items:center; gap:8px; } .c-head .sub{ font-family:var(--mono); font-size:11px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .hstat{ display:inline-flex; align-items:center; gap:5px; font-size:11.5px; - font-weight:600; padding:2px 8px 2px 6px; border-radius:20px; flex:none; } + font-weight:var(--font-weight-600); padding:2px 8px 2px 6px; border-radius:20px; flex:none; } .hstat .sd{ width:6px; height:6px; border-radius:50%; } .hstat.running{ color:var(--warn); background:var(--warn-weak); } .hstat.running .sd{ background:var(--warn); animation:pulse 1.4s ease-in-out infinite; } @@ -177,12 +180,12 @@ body{ gap:14px; padding:6px 0; border-bottom:1px solid var(--divider); } .terminal-control-card dl>div:last-child{ border-bottom:0; } .terminal-control-card dt{ flex:none; color:var(--dim); font-size:11.5px; } -.terminal-control-card dd{ margin:0; min-width:0; color:var(--text); font-size:11.5px; font-weight:600; text-align:right; overflow-wrap:anywhere; } +.terminal-control-card dd{ margin:0; min-width:0; color:var(--text); font-size:11.5px; font-weight:var(--font-weight-600); text-align:right; overflow-wrap:anywhere; } .terminal-control-reason{ display:flex; flex-direction:column; gap:5px; margin-top:10px; padding:8px 10px; border-radius:10px; color:var(--dim); background:var(--raised); font-size:10.5px; } -.terminal-control-reason code{ color:var(--text); font:10.5px/1.45 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; } +.terminal-control-reason code{ color:var(--text); font:var(--font-weight-400) 10.5px/1.45 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; } .terminal-control-takeover{ width:100%; min-height:44px; margin-top:11px; padding:8px 12px; border-radius:10px; - color:#fff; background:var(--accent); font-size:12px; font-weight:650; } + color:var(--on-accent,#fff); background:var(--accent); font-size:12px; font-weight:var(--font-weight-650); } .terminal-control-takeover:hover{ filter:brightness(1.05); } .terminal-control-takeover:disabled{ opacity:.55; cursor:default; filter:none; } @media(max-width:640px){ @@ -231,6 +234,14 @@ body{ .header-menu-item b,.header-menu-modes b{ color:var(--text); font-size:12.5px; } .header-menu-item small,.header-menu-modes small{ color:var(--dim); font-size:10.5px; line-height:1.4; overflow-wrap:anywhere; } +.header-menu-item>.header-menu-switch{ flex:none; width:36px; height:22px; + padding:3px; border:1px solid var(--border-strong); border-radius:999px; + background:var(--raised); transition:background .15s; } +.header-menu-switch::after{ content:""; width:14px; height:14px; border-radius:50%; + background:var(--dim); transition:transform .15s; } +.header-menu-item[aria-checked="true"] .header-menu-switch{ background:var(--accent-weak); border-color:var(--accent); } +.header-menu-item[aria-checked="true"] .header-menu-switch::after{ transform:translateX(14px); background:var(--accent-ink); } +@media (prefers-reduced-motion:reduce){ .header-menu-switch,.header-menu-switch::after{transition:none} } .header-menu-item.danger,.header-menu-item.danger b{ color:var(--danger); } .header-menu-help,.header-menu-warning{ margin:10px 9px 5px; color:var(--dim); font-size:11px; line-height:1.5; } @@ -254,7 +265,7 @@ body{ .work-head{ min-height:58px; padding-left:12px; background:var(--surface); border-bottom:1px solid var(--border); } .work-head::after{ display:none; } .surface-head-title{ display:flex; align-items:center; gap:9px; min-width:0; color:var(--text); - font:600 18px/1.2 var(--serif); letter-spacing:-.01em; } + font:var(--font-weight-600) 18px/1.2 var(--serif); letter-spacing:-.01em; } .surface-head-title:hover{ color:var(--accent-ink); } .surface-head-mark{ width:30px; height:30px; border-radius:9px; display:grid; place-items:center; color:var(--accent-ink); background:var(--accent-weak); border:1px solid var(--accent-line); } @@ -263,13 +274,13 @@ body{ white-space:nowrap; border:1px solid color-mix(in srgb,var(--profile-color) 38%,var(--border)); border-radius:6px; background:color-mix(in srgb,var(--profile-color) 10%,var(--surface)); color:color-mix(in srgb,var(--profile-color) 72%,var(--text)); - font:650 9px/1 var(--mono); letter-spacing:.035em; flex:none; } + font:var(--font-weight-650) 9px/1 var(--mono); letter-spacing:.035em; flex:none; } .work-profile-owner .profile-tone{ width:6px; height:6px; } /* CONNECTION BANNER */ .banner{ display:none; align-items:center; gap:8px; padding:7px max(16px,env(safe-area-inset-right)) 7px max(16px,env(safe-area-inset-left)); - min-height:34px; font-size:12.5px; line-height:1.4; font-weight:600; + min-height:34px; font-size:12.5px; line-height:1.4; font-weight:var(--font-weight-600); background:var(--warn-weak); color:var(--text); border-bottom:1px solid var(--border); flex:none; position:relative; z-index:2; } .banner.show{ display:flex; } @@ -299,7 +310,7 @@ body{ .notice-bar.attention .notice-mark{ background:var(--warn); } .notice-copy{ flex:1; min-width:0; } .notice-title{ display:flex; align-items:baseline; gap:7px; min-width:0; } -.notice-title small{ flex:none; font-size:10px; font-weight:700; letter-spacing:.04em; +.notice-title small{ flex:none; font-size:10px; font-weight:var(--font-weight-700); letter-spacing:.04em; text-transform:uppercase; color:var(--dim); } .notice-title b{ min-width:0; font-size:12.5px; line-height:1.35; overflow-wrap:anywhere; } .notice-copy p{ margin:2px 0 0; font-size:12px; line-height:1.45; overflow-wrap:anywhere; } @@ -307,7 +318,7 @@ body{ .notice-copy summary{ width:max-content; max-width:100%; cursor:pointer; } .notice-copy pre{ margin:4px 0 0; padding:6px 8px; max-height:92px; overflow:auto; border-radius:7px; background:var(--surface); white-space:pre-wrap; overflow-wrap:anywhere; - font:10.5px/1.45 var(--mono); color:var(--dim); } + font:var(--font-weight-400) 10.5px/1.45 var(--mono); color:var(--dim); } .notice-dismiss{ flex:none; display:grid; place-items:center; width:26px; height:26px; border-radius:7px; color:var(--dim); } .notice-dismiss:hover{ color:var(--text); background:var(--surface); } @@ -380,7 +391,7 @@ body{ .ubub-act[data-tooltip]::after{ content:attr(data-tooltip); position:absolute; left:50%; bottom:calc(100% + 7px); z-index:20; padding:5px 8px; border-radius:6px; background:var(--text); color:var(--surface); - box-shadow:var(--shadow); font-size:11px; font-weight:500; line-height:1; white-space:nowrap; + box-shadow:var(--shadow); font-size:11px; font-weight:var(--font-weight-500); line-height:1; white-space:nowrap; opacity:0; visibility:hidden; pointer-events:none; transform:translate(-50%,3px); transition:opacity .12s ease,transform .12s ease,visibility 0s linear .12s; } @@ -403,15 +414,15 @@ body{ .message-code-copy.copied{ color:var(--ok); } .arole{ display:flex; align-items:center; gap:8px; margin:0 0 8px; } /* no box around the mark — just the terracotta spark */ -.arole .av{ width:22px; height:22px; color:var(--accent); display:grid; place-items:center; flex:none; } +.arole .av{ width:22px; height:22px; color:var(--accent-text,var(--accent)); display:grid; place-items:center; flex:none; } .arole .av svg{ width:22px; height:22px; } -.arole .nm{ font-size:12.5px; font-weight:600; color:var(--dim); letter-spacing:.02em; } +.arole .nm{ font-size:12.5px; font-weight:var(--font-weight-600); color:var(--dim); letter-spacing:.02em; } /* prose (react-markdown output) */ .prose{ font-size:15px; line-height:1.62; } .prose p{ margin:0 0 10px; } .prose p:last-child{ margin-bottom:0; } -.prose strong{ font-weight:600; } -.prose h1,.prose h2,.prose h3{ font-family:var(--serif); font-weight:500; margin:18px 0 8px; letter-spacing:-.01em; } +.prose strong{ font-weight:var(--font-weight-600); } +.prose h1,.prose h2,.prose h3{ font-family:var(--serif); font-weight:var(--font-weight-500); margin:18px 0 8px; letter-spacing:-.01em; } .prose h1{ font-size:22px } .prose h2{ font-size:19px } .prose h3{ font-size:16px } .prose code{ font-family:var(--mono); font-size:.86em; background:var(--raised); border:1px solid var(--border); padding:1px 5px; border-radius:5px; } @@ -458,13 +469,13 @@ body{ .prose ul,.prose ol{ margin:8px 0; padding-left:22px; } .prose li{ margin:3px 0; } .prose a{ color:var(--accent-ink); text-decoration:none; border-bottom:1px solid var(--accent-line); } .message-file-link{ display:inline; padding:0; border:0; border-bottom:1px solid var(--accent-line); border-radius:0; background:none; color:var(--accent-ink); font:inherit; cursor:pointer; } -.message-file-link:hover{ color:var(--accent); border-bottom-color:var(--accent); } +.message-file-link:hover{ color:var(--accent-text,var(--accent)); border-bottom-color:var(--accent); } .message-file-tooltip{ position:fixed; z-index:120; display:flex; flex-direction:column; gap:3px; box-sizing:border-box; width:max-content; max-width:min(520px,calc(100vw - 20px)); padding:7px 9px; border:1px solid var(--border-strong); border-radius:7px; color:var(--text); background:var(--surface); box-shadow:var(--shadow-lg); pointer-events:auto; } -.message-file-tooltip-path{ font:11.5px/1.45 var(--mono); overflow-wrap:anywhere; +.message-file-tooltip-path{ font:var(--font-weight-400) 11.5px/1.45 var(--mono); overflow-wrap:anywhere; word-break:break-word; cursor:text; user-select:text; } .message-link-disabled{ color:var(--dim); border-bottom:1px dotted var(--faint); cursor:not-allowed; } .prose blockquote{ border-left:3px solid var(--accent-line); margin:8px 0; padding:2px 12px; color:var(--dim); } @@ -503,10 +514,10 @@ button.message-image-error{ font:inherit; cursor:pointer; } text-overflow:ellipsis; white-space:nowrap; } .codex-visualization-copy>span{ color:var(--dim); font-size:11.5px; line-height:1.35; } .codex-visualization-open{ display:inline-flex; align-items:center; gap:2px; flex:none; - color:var(--accent-ink); font-size:11.5px; font-weight:600; } + color:var(--accent-ink); font-size:11.5px; font-weight:var(--font-weight-600); } .prose table{ display:table; width:100%; border-collapse:collapse; margin:10px 0; font-size:13.5px; } .prose th,.prose td{ border:1px solid var(--border); padding:6px 10px; text-align:left; word-break:break-word; } -.prose th{ background:var(--raised); font-weight:600; } +.prose th{ background:var(--raised); font-weight:var(--font-weight-600); } @media (max-width:980px){ .prose pre{ white-space:pre-wrap; word-break:break-word; overflow-x:hidden; } .prose table{ touch-action:pan-y; } } .cursor{ display:inline-block; width:8px; height:16px; margin-left:1px; background:var(--accent); border-radius:2px; vertical-align:-2px; animation:blink 1.05s steps(2) infinite; } @@ -517,12 +528,12 @@ button.message-image-error{ font:inherit; cursor:pointer; } .thinking span:nth-child(3){ animation-delay:.36s; } /* the "working" indicator lives BELOW the streamed text (so it stays visible as the reply grows). The mark itself is animated — a loop of morphing sparks. */ -.turn-working{ display:inline-flex; align-items:center; gap:7px; margin:8px 0 2px; color:var(--accent); } +.turn-working{ display:inline-flex; align-items:center; gap:7px; margin:8px 0 2px; color:var(--accent-text,var(--accent)); } .turn-working svg{ display:block; } -.turn-working .turn-working-tx{ font-weight:600; color:var(--accent-ink); font-size:12.5px; } +.turn-working .turn-working-tx{ font-weight:var(--font-weight-600); color:var(--accent-ink); font-size:12.5px; } /* the spark kept under a finished reply (below the time/copy row); click to replay */ .turn-done-mark{ margin-top:2px; } -.spark-btn{ display:inline-grid; place-items:center; padding:3px; border-radius:9px; color:var(--accent); transition:.14s; } +.spark-btn{ display:inline-grid; place-items:center; padding:3px; border-radius:9px; color:var(--accent-text,var(--accent)); transition:.14s; } /* no hover box — the click plays the animation; just a subtle grow for affordance */ .spark-btn:hover{ transform:scale(1.12); } .spark-btn svg{ display:block; } @@ -536,7 +547,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .tool-ic{ width:27px; height:27px; border-radius:8px; background:var(--raised); border:1px solid var(--border); display:grid; place-items:center; flex:none; color:var(--dim); } .tool-ic svg{ width:15px; height:15px; } -.tool-nm{ font-family:var(--mono); font-size:13px; font-weight:600; flex:none; } +.tool-nm{ font-family:var(--mono); font-size:13px; font-weight:var(--font-weight-600); flex:none; } .tool-arg{ font-family:var(--mono); font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; } .tool-st{ flex:none; display:grid; place-items:center; width:16px; height:16px; } @@ -555,15 +566,15 @@ button.message-image-error{ font:inherit; cursor:pointer; } .turn-process{ margin:5px 0 10px; color:var(--dim); } .turn-process-controls{ position:relative; display:flex; align-items:center; gap:3px; width:100%; } .turn-process-head{ display:flex; align-items:center; gap:7px; width:100%; min-height:38px; - padding:6px 8px; border-radius:10px; color:var(--dim); text-align:left; font-size:13px; font-weight:600; + padding:6px 8px; border-radius:10px; color:var(--dim); text-align:left; font-size:13px; font-weight:var(--font-weight-600); touch-action:pan-y; user-select:none; -webkit-user-select:none; } .turn-process-controls>.turn-process-head{ width:auto; min-width:0; flex:1; } @media (hover:hover) and (pointer:fine){ .turn-process-head:hover{ background:var(--raised); color:var(--text); } .plan-progress-trigger:hover{ background:var(--accent-weak); } - .process-page-control:hover{ background:var(--raised); color:var(--accent); } + .process-page-control:hover{ background:var(--raised); color:var(--accent-text,var(--accent)); } .process-reasoning > summary:hover,.process-activity > summary:hover{ background:var(--raised); color:var(--text); } - .process-file-link:hover{ color:var(--accent); border-bottom-color:var(--accent); } + .process-file-link:hover{ color:var(--accent-text,var(--accent)); border-bottom-color:var(--accent); } .process-image-preview:hover{ border-color:var(--accent-line); background:var(--accent-weak); } .process-image-preview:disabled:hover{ border-color:var(--line); background:var(--raised); } .tool-group-h:hover{ background:var(--surface); color:var(--text); } @@ -572,13 +583,13 @@ button.message-image-error{ font:inherit; cursor:pointer; } .turn-process-head > svg{ margin-left:2px; color:var(--faint); transition:transform .18s ease; } .turn-process.open .turn-process-head > svg{ transform:rotate(180deg); } .turn-process-state{ width:17px; height:17px; display:grid; place-items:center; flex:none; color:var(--ok); } -.turn-process-state.running{ color:var(--accent); } +.turn-process-state.running{ color:var(--accent-text,var(--accent)); } .turn-process-state.failed{ color:var(--warn); } .turn-process-state.interrupted{ color:var(--dim); } -.turn-process-count{ margin-left:auto; color:var(--faint); font-size:11px; font-weight:500; } +.turn-process-count{ margin-left:auto; color:var(--faint); font-size:11px; font-weight:var(--font-weight-500); } .turn-detail-entry{ display:flex; align-items:center; gap:8px; min-height:30px; margin:3px 0 8px; color:var(--dim); } .turn-detail-entry-btn{ display:inline-flex; align-items:center; gap:6px; min-height:30px; padding:4px 8px; - border-radius:8px; color:var(--dim); font-size:12px; font-weight:600; text-align:left; } + border-radius:8px; color:var(--dim); font-size:12px; font-weight:var(--font-weight-600); text-align:left; } .turn-detail-entry-btn>svg{ transform:rotate(-90deg); color:var(--faint); } .turn-detail-entry-btn:disabled{ opacity:.65; cursor:default; } .turn-detail-entry-error{ color:var(--danger); font-size:11px; line-height:1.4; } @@ -589,7 +600,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } border-right-color:transparent; animation:spin .75s linear infinite; } .plan-progress-control{ position:relative; flex:none; } .plan-progress-trigger{ width:34px; height:34px; display:grid; place-items:center; border-radius:50%; - color:var(--accent); touch-action:pan-y; user-select:none; -webkit-user-select:none; } + color:var(--accent-text,var(--accent)); touch-action:pan-y; user-select:none; -webkit-user-select:none; } .plan-progress-trigger[aria-expanded="true"]{ background:var(--accent-weak); } .plan-progress-trigger.complete{ color:var(--ok); } .plan-progress-trigger.failed{ color:var(--danger); } @@ -609,16 +620,16 @@ button.message-image-error{ font:inherit; cursor:pointer; } .plan-progress-content>header>span:nth-child(2){ min-width:0; flex:1; display:flex; flex-direction:column; } .plan-progress-content>header b{ font-size:13px; } .plan-progress-content>header small{ color:var(--faint); font-size:10.5px; } -.plan-progress-content>header strong{ flex:none; color:var(--dim); font:10.5px/1 var(--mono); } +.plan-progress-content>header strong{ flex:none; color:var(--dim); font:var(--font-weight-400) 10.5px/1 var(--mono); } .plan-progress-mark{ width:29px; height:29px; display:grid; place-items:center; flex:none; - border-radius:9px; color:var(--accent); background:var(--accent-weak); } + border-radius:9px; color:var(--accent-text,var(--accent)); background:var(--accent-weak); } .plan-progress-mark.complete{ color:var(--ok); background:var(--ok-weak); } .plan-progress-mark.failed{ color:var(--danger); background:var(--danger-weak); } .plan-progress-mark.stale{ color:var(--faint); background:var(--surface-soft); } .plan-progress-content>p{ margin:10px 1px 8px; color:var(--dim); font-size:11.5px; line-height:1.45; white-space:pre-wrap; overflow-wrap:anywhere; } .plan-progress-fallback{ margin:9px 0 0; padding:9px 10px; border-radius:9px; overflow:auto; - color:var(--dim); background:var(--raised); font:11.5px/1.5 var(--mono); white-space:pre-wrap; + color:var(--dim); background:var(--raised); font:var(--font-weight-400) 11.5px/1.5 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; } .plan-progress-content>ol{ display:flex; flex-direction:column; gap:1px; margin:9px 0 0; padding:0; list-style:none; } .plan-progress-content>ol li{ display:grid; grid-template-columns:22px minmax(0,1fr); gap:7px; @@ -628,11 +639,11 @@ button.message-image-error{ font:inherit; cursor:pointer; } .plan-progress-content>ol li>span:last-child{ overflow-wrap:anywhere; } .plan-progress-content li.plan-step-completed{ color:var(--faint); } .plan-progress-content li.plan-step-completed>span:first-child{ color:var(--ok); } -.plan-progress-content li.plan-step-inProgress{ color:var(--text); background:var(--accent-weak); font-weight:600; } -.plan-progress-content.stale li.plan-step-inProgress{ color:var(--dim); background:transparent; font-weight:400; } +.plan-progress-content li.plan-step-inProgress{ color:var(--text); background:var(--accent-weak); font-weight:var(--font-weight-600); } +.plan-progress-content.stale li.plan-step-inProgress{ color:var(--dim); background:transparent; font-weight:var(--font-weight-400); } .plan-progress-content li.plan-step-inProgress>span:first-child i{ width:8px; height:8px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 16%,transparent); animation:pulse 1.4s ease-in-out infinite; } -.plan-progress-content li.plan-step-pending>span:first-child em{ font-style:normal; font:9.5px/1 var(--mono); } +.plan-progress-content li.plan-step-pending>span:first-child em{ font-style:normal; font:var(--font-weight-400) 9.5px/1 var(--mono); } .plan-progress-empty{ padding:18px 4px 8px; color:var(--faint); font-size:12px; text-align:center; } .process-timeline{ position:relative; display:flex; flex-direction:column; gap:3px; margin:2px 0 5px 15px; padding:5px 0 5px 17px; border-left:1.5px solid var(--border-strong); } @@ -641,10 +652,10 @@ button.message-image-error{ font:inherit; cursor:pointer; } .process-detail-error{ min-height:35px; display:flex; align-items:center; justify-content:space-between; gap:10px; padding:5px 8px; color:var(--danger); font-size:12.5px; } .process-detail-error button{ flex:none; min-height:28px; padding:3px 10px; border-radius:8px; - color:var(--accent-ink); background:var(--accent-weak); font-weight:600; } + color:var(--accent-ink); background:var(--accent-weak); font-weight:var(--font-weight-600); } .process-page-control{ display:flex; align-items:center; justify-content:center; gap:7px; width:100%; min-height:34px; padding:5px 8px; border-radius:9px; color:var(--accent-ink); - font-size:12px; font-weight:600; touch-action:manipulation; } + font-size:12px; font-weight:var(--font-weight-600); touch-action:manipulation; } .process-page-control:disabled{ color:var(--faint); cursor:default; } .process-page-control.earlier > svg{ transform:rotate(180deg); } .process-commentary{ padding:4px 8px 5px; color:var(--text); } @@ -664,7 +675,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .process-reasoning-body .prose{ font-size:12.5px; line-height:1.55; } .process-item-ic{ width:23px; height:23px; display:grid; place-items:center; flex:none; color:var(--faint); } .process-activity.process-compaction-running{ width:fit-content; gap:10px; border-radius:16px; - color:var(--accent); background:color-mix(in srgb,var(--accent) 7%,transparent); } + color:var(--accent-text,var(--accent)); background:color-mix(in srgb,var(--accent) 7%,transparent); } .compact-motion{ display:flex; align-items:center; justify-content:center; gap:3px; width:31px; height:23px; flex:none; } .compact-motion i{ width:3px; height:13px; border-radius:3px; background:currentColor; @@ -688,11 +699,11 @@ button.message-image-error{ font:inherit; cursor:pointer; } .process-image-preview img,.process-image-placeholder{ width:42px; height:34px; flex:0 0 auto; border-radius:7px; object-fit:cover; background:var(--panel); } .process-image-placeholder{ display:grid; place-items:center; color:var(--muted); } .process-image-preview>span:last-child{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12px; } -.process-meta{ color:var(--faint); font:10.5px/1.4 var(--mono); overflow-wrap:anywhere; } +.process-meta{ color:var(--faint); font:var(--font-weight-400) 10.5px/1.4 var(--mono); overflow-wrap:anywhere; } .process-plan{ display:flex; flex-direction:column; gap:5px; margin:2px 0; padding:0; list-style:none; } .process-plan li{ display:grid; grid-template-columns:17px 1fr; gap:6px; color:var(--dim); font-size:12.5px; line-height:1.45; } .process-plan li.plan-completed{ color:var(--faint); } -.process-plan li.plan-inProgress{ color:var(--text); font-weight:600; } +.process-plan li.plan-inProgress{ color:var(--text); font-weight:var(--font-weight-600); } .process-command{ max-height:260px; } @media (max-width:980px){ .process-timeline{ margin-left:8px; padding-left:11px; } @@ -707,11 +718,11 @@ button.message-image-error{ font:inherit; cursor:pointer; } user-select:none; -webkit-user-select:none; } .tool-group-h::-webkit-details-marker{ display:none; } .tool-group-ic{ display:grid; place-items:center; width:16px; height:16px; flex:none; color:var(--faint); } -.tool-group-ic.running{ color:var(--accent); } +.tool-group-ic.running{ color:var(--accent-text,var(--accent)); } .tool-group-ic .spin-dot{ width:11px; height:11px; border-radius:50%; border:2px solid var(--accent); border-right-color:transparent; animation:spin .7s linear infinite; display:block; } .tool-group-ic svg{ width:13px; height:13px; color:var(--ok); } -.tool-group-nm{ font-weight:600; } +.tool-group-nm{ font-weight:var(--font-weight-600); } .tool-group-sub{ font-family:var(--mono); font-size:11.5px; color:var(--faint); } .tool-group-err{ color:var(--danger); } .tool-group-chev{ margin-left:auto; color:var(--faint); transition:transform .2s; } @@ -726,7 +737,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .tool-group-b .tool-st{ width:14px; height:14px; } .tool-group-b .tool-st svg,.tool-group-b .tool-chev svg{ width:13px; height:13px; } .tool-group-b .tool-b{ padding:2px 5px 8px 32px; } -.tool-lbl{ font-size:10.5px; font-weight:600; letter-spacing:.05em; text-transform:uppercase; +.tool-lbl{ font-size:10.5px; font-weight:var(--font-weight-600); letter-spacing:.05em; text-transform:uppercase; color:var(--faint); margin:8px 0 4px; } .tool-pre{ font-family:var(--mono); font-size:12px; line-height:1.5; background:var(--bg); border:1px solid var(--border); border-radius:9px; padding:9px 10px; margin:0; @@ -735,12 +746,12 @@ button.message-image-error{ font:inherit; cursor:pointer; } .diff-table{ font-family:var(--mono); font-size:12px; line-height:1.55; background:var(--raised); border:1px solid var(--border); border-radius:9px; overflow:hidden; } .diff-page-nav{ position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:center; gap:10px; margin:0 0 8px; padding:7px 9px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); } -.diff-page-nav span{ color:var(--dim); font:11px/1.4 var(--mono); } +.diff-page-nav span{ color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); } .diff-page-nav button{ padding:4px 8px; border-radius:7px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; } .diff-page-nav button:disabled{ color:var(--faint); background:var(--raised); cursor:default; } .diff-empty{ color:var(--dim); padding:24px; text-align:center; } .diff-file + .diff-file{ border-top:1px solid var(--border-strong); } -.diff-file-h{ display:flex; align-items:center; gap:7px; padding:8px 12px; background:var(--bg); color:var(--text); font-weight:600; border-bottom:1px solid var(--border); font-size:12px; } +.diff-file-h{ display:flex; align-items:center; gap:7px; padding:8px 12px; background:var(--bg); color:var(--text); font-weight:var(--font-weight-600); border-bottom:1px solid var(--border); font-size:12px; } .diff-file-nm{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .diff-hunk-h{ padding:5px 12px; color:var(--faint); background:var(--surface); border-bottom:1px solid var(--border); white-space:pre-wrap; overflow-wrap:anywhere; } .drow{ display:grid; grid-template-columns:3.2ch 3.2ch 1fr; align-items:baseline; } @@ -763,7 +774,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .turn-changes button:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; } .turn-changes-toggle > svg{ transform:rotate(-90deg); transition:transform .15s; } .turn-changes-toggle[aria-expanded="true"] > svg{ transform:rotate(0); } -.turn-changes-counts{ display:flex; gap:6px; margin-left:4px; font:11px var(--mono); } +.turn-changes-counts{ display:flex; gap:6px; margin-left:4px; font:var(--font-weight-400) 11px var(--mono); } .turn-changes-counts > :first-child{ color:var(--ok); } .turn-changes-counts > :last-child{ color:var(--danger); } .turn-changes-files{ margin-top:6px; border:1px solid var(--border); border-radius:12px; overflow:hidden; } @@ -773,10 +784,10 @@ button.message-image-error{ font:inherit; cursor:pointer; } .turn-change-file{ display:flex; align-items:center; gap:10px; min-width:0; padding:9px 12px; } .turn-change-file + .turn-change-file{ border-top:1px solid var(--border); } .turn-change-path{ display:flex; align-items:center; gap:8px; flex:1; min-width:0; - padding:0; background:none; color:var(--text); text-align:left; font:12px var(--mono); border-radius:4px; } + padding:0; background:none; color:var(--text); text-align:left; font:var(--font-weight-400) 12px var(--mono); border-radius:4px; } .turn-change-path:disabled{ opacity:1; cursor:default; } .turn-change-type{ display:flex; align-items:center; justify-content:center; flex:none; - width:28px; height:28px; border-radius:7px; font:600 10px var(--mono); + width:28px; height:28px; border-radius:7px; font:var(--font-weight-600) 10px var(--mono); color:var(--dim); background:color-mix(in srgb,currentColor 7%,transparent); } .turn-change-type[data-tone="blue"]{ color:light-dark(#3b70ad,#8db6e8); } .turn-change-type[data-tone="amber"]{ color:light-dark(#947025,#dbc185); } @@ -784,7 +795,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .turn-change-type[data-tone="green"]{ color:light-dark(#3d7b61,#8cc6a7); } .turn-change-type[data-tone="cyan"]{ color:light-dark(#347e8d,#88c2cf); } .turn-change-label{ display:flex; align-items:baseline; flex-wrap:wrap; gap:2px 10px; min-width:0; } -.turn-change-name{ font-weight:550; overflow-wrap:anywhere; } +.turn-change-name{ font-weight:var(--font-weight-550); overflow-wrap:anywhere; } .turn-change-directory{ color:var(--dim); font-size:10.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .turn-change-path:not(:disabled):hover{ color:var(--accent-ink); } .turn-change-file small{ color:var(--faint); font-size:10px; flex:none; } @@ -822,7 +833,7 @@ button.message-image-error{ font:inherit; cursor:pointer; } .panel-resizer:hover::after,.panel-resizer:focus-visible::after{ opacity:1; background:var(--accent); } html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-select:none !important; } .artifact-head{ display:flex; align-items:center; gap:10px; min-width:0; padding:12px 14px; border-bottom:1px solid var(--border); flex:none; padding-top:max(12px, env(safe-area-inset-top)); } -.artifact-title{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; font-size:14px; flex:0 1 auto; } +.artifact-title{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:var(--font-weight-600); font-size:14px; flex:0 1 auto; } .artifact-path{ font-family:var(--mono); font-size:11px; color:var(--dim); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .artifact-body{ flex:1; min-width:0; min-height:0; max-width:100%; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; touch-action:pan-y; -webkit-overflow-scrolling:touch; padding:14px 16px; } .artifact-body.rendered-artifact-body{ display:flex; overflow:hidden; padding:0; background:var(--raised); } @@ -831,7 +842,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .source-artifact-body .preview-truncated,.source-artifact-body .markdown-save-error{ flex:none; } .artifact-body .diff-pre{ margin:0; } .artifact-body .prose{ font-size:14px; } -.artifact-converted{ flex:none; border:1px solid var(--border); border-radius:7px; padding:3px 6px; color:var(--dim); background:var(--raised); font:9.5px/1.2 var(--mono); white-space:nowrap; } +.artifact-converted{ flex:none; border:1px solid var(--border); border-radius:7px; padding:3px 6px; color:var(--dim); background:var(--raised); font:var(--font-weight-400) 9.5px/1.2 var(--mono); white-space:nowrap; } .artifact-html-preview{ display:block; width:100%; min-width:0; height:100%; border:0; background:#fff; } .artifact-html-stage{ display:flex; flex:1; width:100%; min-width:0; height:100%; flex-direction:column; } .artifact-html-stage .artifact-html-preview{ flex:1; min-height:0; } @@ -853,7 +864,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .artifact-audio-error{ margin:12px 0 0; color:var(--danger); font-size:12px; line-height:1.6; } .artifact-pdf-stage{ display:flex; flex:1; width:100%; min-width:0; height:100%; min-height:0; flex-direction:column; } .artifact-pdf-controls{ display:flex; flex:none; min-height:38px; align-items:center; justify-content:center; gap:10px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); } -.artifact-pdf-controls span{ min-width:76px; color:var(--dim); font:11px/1.4 var(--mono); text-align:center; } +.artifact-pdf-controls span{ min-width:76px; color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); text-align:center; } .artifact-pdf-controls button{ border:1px solid var(--border); border-radius:7px; padding:4px 8px; background:var(--raised); color:var(--accent-ink); font-size:11px; } .artifact-pdf-controls button:disabled{ color:var(--faint); cursor:default; } .artifact-pdf-page{ position:relative; display:flex; flex:1; min-width:0; min-height:0; align-items:flex-start; justify-content:center; overflow:auto; overscroll-behavior:contain; padding:12px; touch-action:pan-x pan-y; } @@ -864,7 +875,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .preview-modes{ display:flex; gap:2px; flex:none; padding:2px; border:1px solid var(--border); border-radius:8px; background:var(--raised); } .preview-modes button{ padding:4px 8px; border-radius:6px; color:var(--dim); font-size:11px; } .preview-modes button.on{ color:var(--accent-ink); background:var(--surface); box-shadow:var(--shadow-sm); } -.markdown-save{ display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 9px; border:1px solid var(--accent-line); border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; font-weight:650; } +.markdown-save{ display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 9px; border:1px solid var(--accent-line); border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; font-weight:var(--font-weight-650); } .markdown-save:disabled{ color:var(--faint); border-color:var(--border); background:var(--raised); cursor:default; } .markdown-save-state{ flex:none; font-size:11px; color:var(--dim); } .markdown-save-state.ok{ color:var(--success); } @@ -875,14 +886,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .preview-authorization{ display:flex; align-items:center; gap:8px; margin:auto; padding:14px; color:var(--dim); font-size:13px; } .preview-authorization.compact{ display:inline-flex; margin:6px 0; padding:8px 0; font-size:12px; } .preview-truncated{ margin-bottom:12px; color:var(--dim); background:var(--raised); border:1px solid var(--border); } -.markdown-editor{ display:block; box-sizing:border-box; width:100%; min-height:calc(100% - 2px); resize:none; border:1px solid var(--border); border-radius:9px; padding:12px 14px; background:var(--raised); color:var(--text); font:12.5px/1.65 var(--mono); tab-size:2; white-space:pre-wrap; overflow-wrap:anywhere; } +.markdown-editor{ display:block; box-sizing:border-box; width:100%; min-height:calc(100% - 2px); resize:none; border:1px solid var(--border); border-radius:9px; padding:12px 14px; background:var(--raised); color:var(--text); font:var(--font-weight-400) 12.5px/1.65 var(--mono); tab-size:2; white-space:pre-wrap; overflow-wrap:anywhere; } .markdown-editor:focus{ outline:none; border-color:var(--accent-line); box-shadow:0 0 0 2px var(--accent-weak); } .markdown-editor:read-only{ color:var(--dim); cursor:not-allowed; } .source-page-nav{ position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:center; gap:10px; margin:0 0 8px; padding:7px 9px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); } -.source-page-nav span{ color:var(--dim); font:11px/1.4 var(--mono); } +.source-page-nav span{ color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); } .source-page-nav button{ padding:4px 8px; border-radius:7px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; } .source-page-nav button:disabled{ color:var(--faint); background:var(--raised); cursor:default; } -.source-file{ min-width:0; width:100%; border:1px solid var(--border); border-radius:9px; overflow:hidden; background:var(--raised); font:12px/1.6 var(--mono); tab-size:2; } +.source-file{ min-width:0; width:100%; border:1px solid var(--border); border-radius:9px; overflow:hidden; background:var(--raised); font:var(--font-weight-400) 12px/1.6 var(--mono); tab-size:2; } .source-line{ display:grid; grid-template-columns:5.5ch minmax(0,1fr); min-height:1.6em; } .source-line-no{ padding:0 9px 0 5px; color:var(--faint); text-align:right; user-select:none; border-right:1px solid var(--border); background:var(--bg); } .source-line code{ display:block; min-width:0; padding:0 11px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; color:var(--text); } @@ -922,9 +933,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .btw-panel{ position:fixed; top:14px; right:14px; bottom:14px; width:var(--panel-w); z-index:36; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-panel); box-shadow:var(--shadow-lg); display:flex; flex-direction:column; overflow:hidden; animation:panel-in .24s var(--ease); } .btw-head{ display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border); flex:none; padding-top:max(12px, env(safe-area-inset-top)); } .btw-titles{ flex:1; display:flex; flex-direction:column; gap:1px; min-width:0; } -.btw-title{ font-weight:650; font-size:13px; color:var(--accent); } +.btw-title{ font-weight:var(--font-weight-650); font-size:13px; color:var(--accent-text,var(--accent)); } .btw-sub{ font-size:11px; color:var(--dim); } -.btw-new{ color:var(--accent); } +.btw-new{ color:var(--accent-text,var(--accent)); } .btw-chat-tabs{ flex:none; display:flex; gap:6px; padding:8px 10px; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--surface) 88%,var(--bg)); } .btw-chat-tab-wrap{ flex:0 0 auto; max-width:190px; display:flex; align-items:center; border:1px solid var(--border); border-radius:9px; background:var(--bg); color:var(--dim); transition:border-color .16s var(--ease),background .16s var(--ease),color .16s var(--ease); } .btw-chat-tab-wrap.active{ border-color:color-mix(in srgb,var(--accent) 58%,var(--border)); background:color-mix(in srgb,var(--accent) 10%,var(--surface)); color:var(--text); } @@ -932,7 +943,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .btw-chat-label{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .btw-chat-state{ width:6px; height:6px; flex:none; border-radius:50%; background:var(--border-strong); } .btw-chat-state.running,.btw-chat-state.interrupting,.btw-chat-state.draining{ background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent); } -.btw-chat-question{ flex:none; border-radius:999px; padding:1px 5px; color:var(--accent); background:color-mix(in srgb,var(--accent) 14%,transparent); font-size:9px; line-height:14px; } +.btw-chat-question{ flex:none; border-radius:999px; padding:1px 5px; color:var(--accent-text,var(--accent)); background:color-mix(in srgb,var(--accent) 14%,transparent); font-size:9px; line-height:14px; } .btw-chat-close{ width:26px; height:28px; flex:none; display:grid; place-items:center; border:0; border-left:1px solid transparent; background:transparent; color:var(--dim); cursor:pointer; } .btw-chat-close:hover{ color:var(--danger); } .btw-body{ flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; padding:4px 2px; } @@ -950,7 +961,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .btw-input textarea:focus{ outline:none; border-color:var(--accent); } .btw-input textarea:disabled{ background:color-mix(in srgb,var(--dim) 10%,var(--bg)); color:var(--dim); border-color:var(--border); cursor:not-allowed; opacity:.65; } .btw-readonly-notice{ color:var(--dim); font-size:12px; line-height:1.5; margin-bottom:8px; overflow-wrap:anywhere; } -.btw-send{ flex:none; width:38px; height:38px; border-radius:10px; border:0; background:var(--accent); color:#fff; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; } +.btw-send{ flex:none; width:38px; height:38px; border-radius:10px; border:0; background:var(--accent); color:var(--on-accent,#fff); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; } .btw-send.interrupt{ background:var(--danger); } .btw-send:disabled{ opacity:.4; cursor:default; } .btw-controls{ display:flex; align-items:center; justify-content:flex-end; gap:12px; min-width:0; padding:7px 2px 0; color:var(--faint); font-size:10px; } @@ -961,8 +972,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se /* Shared tabs for the right-side artifact and btw surfaces. */ .panel-tabs{ display:flex; gap:3px; flex:1; min-width:0; overflow-x:auto; scrollbar-width:none; } .panel-tabs::-webkit-scrollbar{ display:none; } -.ptab{ display:inline-flex; align-items:center; gap:5px; border:0; background:none; padding:5px 11px; border-radius:8px; font:inherit; font-size:13px; font-weight:650; color:var(--dim); cursor:pointer; white-space:nowrap; transition:color .12s, background .12s; } -.ptab.on{ color:var(--accent); background:var(--accent-weak); } +.ptab{ display:inline-flex; align-items:center; gap:5px; border:0; background:none; padding:5px 11px; border-radius:8px; font:inherit; font-size:13px; font-weight:var(--font-weight-650); color:var(--dim); cursor:pointer; white-space:nowrap; transition:color .12s, background .12s; } +.ptab.on{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); } .ptab:not(.on):hover{ color:var(--text); } /* desktop: an open right panel PUSHES the chat left (split), not covers it. @@ -989,9 +1000,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se /* EMPTY STATE */ .empty{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:40px; gap:6px; } -.empty .glyph{ color:var(--accent); display:grid; place-items:center; margin-bottom:14px; } +.empty .glyph{ color:var(--accent-text,var(--accent)); display:grid; place-items:center; margin-bottom:14px; } .empty .glyph svg{ width:48px; height:48px; } -.empty h2{ font-family:var(--serif); font-weight:500; font-size:25px; margin:0; letter-spacing:-.01em; } +.empty h2{ font-family:var(--serif); font-weight:var(--font-weight-500); font-size:25px; margin:0; letter-spacing:-.01em; } .empty p{ color:var(--dim); margin:0; max-width:280px; font-size:14px; } .empty .spinner{ width:30px; height:30px; border-radius:50%; margin-bottom:14px; border:3px solid var(--border); border-top-color:var(--accent); animation:spin .7s linear infinite; } @@ -1010,7 +1021,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .paste-card-icon{ display:grid; width:26px; height:26px; flex:none; place-items:center; color:var(--dim); background:transparent; } .paste-card-body{ display:flex; min-width:0; flex:1; flex-direction:column; gap:2px; } -.paste-card-preview{ overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; +.paste-card-preview{ overflow:hidden; color:var(--text); font-size:12.5px; font-weight:var(--font-weight-550); text-overflow:ellipsis; white-space:nowrap; } .paste-card-meta{ color:var(--faint); font-size:10.5px; } .paste-card>.attach-x{ position:absolute; top:5px; right:5px; opacity:.48; @@ -1030,13 +1041,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se background:transparent; color:var(--dim); cursor:pointer; } .paste-preview textarea{ flex:1; min-height:0; resize:none; margin:12px 14px; padding:12px; border:1px solid var(--border); border-radius:10px; outline:0; background:var(--raised); - color:var(--text); font:12px/1.6 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; } + color:var(--text); font:var(--font-weight-400) 12px/1.6 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; } .paste-preview textarea:focus{ border-color:var(--accent-line); box-shadow:0 0 0 3px var(--accent-weak); } .paste-preview footer{ display:flex; justify-content:flex-end; gap:8px; padding:10px 14px; border-top:1px solid var(--border); } .paste-preview footer button{ min-width:70px; padding:7px 12px; border:1px solid var(--border); border-radius:9px; background:var(--surface); color:var(--dim); font-size:12px; } -.paste-preview footer button.primary{ border-color:var(--accent); background:var(--accent); color:white; } +.paste-preview footer button.primary{ border-color:var(--accent); background:var(--accent); color:var(--on-accent,#fff); } .paste-preview footer button:disabled{ opacity:.45; cursor:default; } @media(max-width:979px){ .paste-card{ width:min(86vw,300px); } @@ -1049,7 +1060,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se background:var(--surface); box-shadow:var(--shadow-sm); } .work-compose-caption{ display:flex; align-items:center; gap:9px; min-width:0; padding:0 2px 9px; color:var(--dim); } .work-compose-caption>span:nth-child(2){ display:flex; flex:1; min-width:0; flex-direction:column; } -.work-compose-caption b{ color:var(--text); font-size:12.5px; font-weight:600; } +.work-compose-caption b{ color:var(--text); font-size:12.5px; font-weight:var(--font-weight-600); } .work-compose-caption small{ overflow:hidden; color:var(--faint); font-size:10.5px; text-overflow:ellipsis; white-space:nowrap; } .work-compose-icon{ width:28px; height:28px; flex:none; display:grid; place-items:center; border-radius:9px; color:var(--accent-ink); background:var(--accent-weak); } @@ -1075,7 +1086,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .capabilities-sheet>header small{ color:var(--dim); } .capabilities-head-actions{ display:flex; gap:6px; } .capabilities-tabs{ display:flex; flex:none; gap:4px; padding:9px 14px; overflow-x:auto; border-bottom:1px solid var(--border); } -.capabilities-tabs button{ flex:none; padding:7px 11px; border-radius:9px; color:var(--dim); font-size:12px; font-weight:650; } +.capabilities-tabs button{ flex:none; padding:7px 11px; border-radius:9px; color:var(--dim); font-size:12px; font-weight:var(--font-weight-650); } .capabilities-tabs button.active{ color:var(--accent-ink); background:var(--accent-weak); box-shadow:inset 0 0 0 1px var(--accent-line); } .capabilities-body{ flex:1; min-height:0; padding:18px; overflow:auto; overscroll-behavior:contain; } .capabilities-note,.capabilities-errors{ @@ -1085,7 +1096,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .capabilities-errors{ display:flex; flex-direction:column; gap:4px; color:var(--danger); } .capabilities-group{ margin:0 0 20px; } .capabilities-group h3{ display:flex; align-items:center; gap:8px; margin:0 0 8px; font-size:15px; } -.capabilities-group h3 span{ color:var(--dim); font-size:12px; font-weight:500; } +.capabilities-group h3 span{ color:var(--dim); font-size:12px; font-weight:var(--font-weight-500); } .capabilities-group article{ display:flex; align-items:flex-start; justify-content:space-between; gap:18px; padding:12px 2px; border-bottom:1px solid var(--border); @@ -1102,7 +1113,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se } .capabilities-item-actions button.danger{ color:var(--danger); border-color:color-mix(in srgb,var(--danger) 40%,transparent); } .capabilities-create{ margin-bottom:18px; padding:12px; border:1px solid var(--border); border-radius:14px; background:var(--raised); } -.capabilities-create>button{ color:var(--accent-ink); font-size:13px; font-weight:700; } +.capabilities-create>button{ color:var(--accent-ink); font-size:13px; font-weight:var(--font-weight-700); } .capabilities-create form{ display:grid; gap:9px; margin-top:12px; } .capabilities-create input,.capabilities-create textarea,.capabilities-create select{ width:100%; min-height:40px; padding:9px 11px; border:1px solid var(--border); border-radius:9px; @@ -1135,16 +1146,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .device-pairing{ display:grid; grid-template-columns:1fr auto; align-items:start; gap:12px; padding:16px; border:1px solid var(--border); border-radius:16px; background:var(--raised); } .device-pairing b{ font-size:14px; } .device-pairing p{ margin:4px 0 0; color:var(--dim); font-size:12px; line-height:1.5; } -.device-pairing>button,.device-code>button{ padding:8px 11px; border:1px solid var(--accent-line); border-radius:9px; color:var(--accent-ink); background:var(--accent-weak); font-size:12px; font-weight:650; } +.device-pairing>button,.device-code>button{ padding:8px 11px; border:1px solid var(--accent-line); border-radius:9px; color:var(--accent-ink); background:var(--accent-weak); font-size:12px; font-weight:var(--font-weight-650); } .device-pairing>button.subtle{ color:var(--dim); border-color:var(--border); background:var(--surface); } .device-code{ grid-column:1/-1; display:flex; flex-direction:column; align-items:flex-start; gap:8px; padding-top:12px; border-top:1px solid var(--border); } .device-code>span,.device-code>small{ color:var(--dim); font-size:11px; } -.device-code>strong{ color:var(--accent-ink); font:700 20px/1.2 var(--mono); letter-spacing:.06em; } -.device-code>code{ display:block; width:100%; padding:10px 12px; overflow:auto; border:1px solid var(--border); border-radius:9px; background:var(--surface); font:11px/1.45 var(--mono); white-space:nowrap; } +.device-code>strong{ color:var(--accent-ink); font:var(--font-weight-700) 20px/1.2 var(--mono); letter-spacing:.06em; } +.device-code>code{ display:block; width:100%; padding:10px 12px; overflow:auto; border:1px solid var(--border); border-radius:9px; background:var(--surface); font:var(--font-weight-400) 11px/1.45 var(--mono); white-space:nowrap; } .device-pairing-live{ grid-column:1/-1; padding-top:10px; border-top:1px solid var(--border); } .device-list{ margin-top:20px; } .device-list h3{ display:flex; gap:8px; margin:0 0 9px; font-size:14px; } -.device-list h3 span{ color:var(--dim); font-size:12px; font-weight:500; } +.device-list h3 span{ color:var(--dim); font-size:12px; font-weight:var(--font-weight-500); } .device-list article{ position:relative; margin-bottom:9px; overflow:hidden; border:1px solid var(--border); border-radius:14px; background:var(--surface); } .device-list article.current{ border-color:var(--accent-line); box-shadow:inset 3px 0 0 var(--accent); } .device-main{ display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:11px; width:100%; padding:13px 14px; text-align:left; } @@ -1176,7 +1187,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .work-compose-foot{ position:relative; display:flex; align-items:center; gap:4px; min-height:37px; padding-top:8px; border-top:1px solid var(--border); } .work-compose-tool,.work-settings>summary{ display:flex; align-items:center; gap:6px; min-height:30px; - padding:5px 8px; border-radius:8px; color:var(--dim); font-size:11.5px; font-weight:600; cursor:pointer; } + padding:5px 8px; border-radius:8px; color:var(--dim); font-size:11.5px; font-weight:var(--font-weight-600); cursor:pointer; } .work-compose-tool:hover,.work-settings>summary:hover{ color:var(--text); background:var(--raised); } .work-compose-tool:disabled{ opacity:.45; cursor:default; } .work-settings{ position:relative; } @@ -1188,7 +1199,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se padding:9px 10px; border-radius:9px; color:var(--dim); text-align:left; font-size:12px; } .work-settings-pop>button:hover{ color:var(--text); background:var(--raised); } .work-settings-pop>button:disabled{ opacity:.45; cursor:default; background:transparent; } -.work-settings-pop>button b{ max-width:160px; overflow:hidden; color:var(--text); font-weight:600; +.work-settings-pop>button b{ max-width:160px; overflow:hidden; color:var(--text); font-weight:var(--font-weight-600); text-overflow:ellipsis; white-space:nowrap; } .work-ctx-pop{ right:0; left:auto; bottom:calc(100% + 8px); } .work-ctx-details{ display:flex; flex-direction:column; gap:6px; padding-top:10px; border-top:1px solid var(--border); } @@ -1221,8 +1232,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se animation:popin .16s var(--ease); overscroll-behavior:contain; touch-action:pan-y; } @keyframes popin{ from{ opacity:0; transform:translateY(6px) } to{ opacity:1; transform:none } } .composer-notice{ position:absolute; left:50%; transform:translateX(-50%); bottom:calc(100% + 10px); - z-index:22; width:max-content; max-width:92%; background:var(--accent); color:#fff; - padding:7px 14px; border-radius:999px; font-size:13px; font-weight:500; box-shadow:var(--shadow-lg); } + z-index:22; width:max-content; max-width:92%; background:var(--accent); color:var(--on-accent,#fff); + padding:7px 14px; border-radius:999px; font-size:13px; font-weight:var(--font-weight-500); box-shadow:var(--shadow-lg); } .queued{ display:none; flex-wrap:wrap; gap:6px; padding:4px 4px 8px; } .queued.show{ display:flex; } .qchip{ display:inline-flex; align-items:center; gap:2px; max-width:100%; min-width:0; @@ -1238,7 +1249,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se padding:0; border:0; border-radius:5px; background:transparent; color:var(--faint); } .qchip .qx:hover{ background:var(--danger-weak); color:var(--danger); } .qchip .qx svg{ width:12px; height:12px; } -.qchip .qbadge{ flex:none; font-size:10px; font-weight:700; color:var(--accent-ink); background:var(--accent-weak); padding:1px 6px; border-radius:20px; } +.qchip .qbadge{ flex:none; font-size:10px; font-weight:var(--font-weight-700); color:var(--accent-ink); background:var(--accent-weak); padding:1px 6px; border-radius:20px; } .qchip.error .qbadge{ color:var(--danger); background:var(--danger-weak); } .queued-query-sheet{ min-height:min(420px,70dvh); } .queued-query-head{ display:flex; align-items:flex-start; justify-content:space-between; @@ -1266,9 +1277,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se padding:10px 18px calc(12px + env(safe-area-inset-bottom)); border-top:1px solid var(--divider); } .queued-query-actions button{ display:inline-flex; min-height:38px; align-items:center; justify-content:center; gap:6px; border-radius:10px; padding:0 15px; - font-size:13px; font-weight:600; } + font-size:13px; font-weight:var(--font-weight-600); } .queued-query-actions button:disabled{ opacity:.5; cursor:default; } -.queued-query-primary{ background:var(--accent); color:#fff; } +.queued-query-primary{ background:var(--accent); color:var(--on-accent,#fff); } .queued-query-secondary{ border:1px solid var(--border); background:var(--surface); color:var(--dim); } .attach{ display:none; flex-wrap:wrap; gap:6px; padding:4px 4px 8px; } .attach.show{ display:flex; } @@ -1295,9 +1306,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se border:2.5px dashed var(--accent); border-radius:22px; background:var(--surface); box-shadow:0 12px 40px rgba(0,0,0,.22); } .drop-card .dc-ic{ display:grid; place-items:center; width:64px; height:64px; border-radius:50%; - background:var(--accent-weak); color:var(--accent); } + background:var(--accent-weak); color:var(--accent-text,var(--accent)); } .drop-card .dc-ic svg{ width:36px; height:36px; } -.drop-card .dc-tx{ font-size:15.5px; font-weight:600; color:var(--text); } +.drop-card .dc-tx{ font-size:15.5px; font-weight:var(--font-weight-600); color:var(--text); } .drop-card .dc-sub{ font-size:12.5px; color:var(--dim); } .ubub-imgs{ display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:flex-end; gap:8px; width:fit-content; max-width:100%; margin-bottom:6px; } .ubub-image-trigger{ display:block; position:relative; flex:0 1 auto; width:var(--user-image-width); max-width:100%; padding:0; border:1px solid var(--border); @@ -1355,13 +1366,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se } .ubub-files{ display:flex; flex-wrap:wrap; gap:6px; max-width:82%; margin-left:auto; margin-bottom:6px; } .ubub-file{ display:inline-flex; align-items:center; gap:6px; background:var(--accent-weak); border:1px solid var(--accent-line); border-radius:8px; padding:5px 9px; font-size:12.5px; color:var(--text); } -.ubub-file svg{ width:14px; height:14px; color:var(--accent); } +.ubub-file svg{ width:14px; height:14px; color:var(--accent-text,var(--accent)); } /* control bar (model + perm chips, idle only) */ .ctrlbar{ display:none; align-items:center; gap:8px; padding:2px 2px 9px; flex-wrap:wrap; } .ctrlbar.show{ display:flex; } /* frameless bottom-bar controls (model / effort) — no box, just text */ -.hint-ctl{ border:0; background:none; padding:0; font:inherit; font-weight:600; font-size:12px; +.hint-ctl{ border:0; background:none; padding:0; font:inherit; font-weight:var(--font-weight-600); font-size:12px; color:var(--text); cursor:pointer; white-space:nowrap; transition:color .14s; } .hint-ctl:hover{ color:var(--accent-ink); } .hint-ctl:disabled{ opacity:.45; cursor:default; color:inherit; } @@ -1369,7 +1380,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se border:1px solid var(--accent-line); border-radius:999px; padding:1px 8px; } /* Codex Fast mode keeps a stable text-only footprint in both states. */ .fast-chip{ flex:none; color:var(--dim); } -.fast-chip.on{ color:var(--accent); } +.fast-chip.on{ color:var(--accent-text,var(--accent)); } /* context-usage ring */ .hint-ring{ border:0; background:none; padding:0; display:inline-flex; cursor:pointer; line-height:0; flex:none; } .hint-ring svg{ display:block; transition:transform .14s; } @@ -1455,12 +1466,12 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .codex-context-control p[role="alert"]{ color:var(--danger); } .codex-context-input{ display:flex; gap:6px; margin-top:6px; } .codex-context-input input{ width:100%; min-width:0; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); } -.codex-context-input button{ flex:none; padding:8px 12px; background:var(--accent); color:white; border-radius:8px; } +.codex-context-input button{ flex:none; padding:8px 12px; background:var(--accent); color:var(--on-accent,#fff); border-radius:8px; } .codex-context-default{ align-self:flex-start; color:var(--accent-ink); padding:5px 0; } .auto-compact-pop .auto-compact-control{ margin:0; padding:0; border:0; } .auto-compact-head{ display:flex; align-items:center; justify-content:space-between; gap:10px; color:var(--dim); font-size:11.5px; } -.auto-compact-head b{ color:var(--text); font-size:11px; font-weight:650; } +.auto-compact-head b{ color:var(--text); font-size:11px; font-weight:var(--font-weight-650); } .auto-compact-options{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; } .auto-compact-options button{ min-width:0; padding:6px 5px; border:1px solid var(--border); border-radius:8px; background:var(--raised); color:var(--dim); font-size:10.5px; @@ -1474,10 +1485,10 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .auto-compact-custom label{ display:flex; align-items:center; flex:1; min-width:0; border:1px solid var(--border); border-radius:8px; background:var(--bg); overflow:hidden; } .auto-compact-custom input{ min-width:0; width:100%; padding:7px 4px 7px 8px; - border:0; outline:0; background:transparent; color:var(--text); font:11px var(--mono); } + border:0; outline:0; background:transparent; color:var(--text); font:var(--font-weight-400) 11px var(--mono); } .auto-compact-custom label span{ flex:none; padding-right:8px; color:var(--faint); font-size:9.5px; } .auto-compact-custom>button{ padding:7px 10px; border-radius:8px; - background:var(--accent); color:#fff; font-size:10.5px; } + background:var(--accent); color:var(--on-accent,#fff); font-size:10.5px; } .auto-compact-custom>button:disabled{ opacity:.45; cursor:default; } .auto-compact-meta{ display:flex; flex-direction:column; gap:3px; color:var(--faint); font-size:10px; line-height:1.45; } @@ -1492,14 +1503,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se /* Composer activity: send mode and independently running background tasks. */ .runbar{ display:none; align-items:center; gap:8px; padding:2px 2px 9px; } .runbar.show{ display:flex; } -.stopbtn{ display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:600; +.stopbtn{ display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:var(--font-weight-600); color:var(--danger); background:var(--danger-weak); border:1px solid transparent; padding:7px 13px 7px 11px; border-radius:11px; transition:.14s; } .stopbtn:hover{ filter:brightness(.97); } .stopbtn svg{ width:13px; height:13px; } .seg{ display:flex; margin-left:auto; background:var(--surface); border:1px solid var(--border); border-radius:11px; padding:3px; gap:2px; } -.seg button{ display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; +.seg button{ display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:var(--font-weight-600); color:var(--dim); padding:6px 11px; border-radius:8px; transition:.14s; } .seg button svg{ width:14px; height:14px; } .seg button.on{ background:var(--accent-weak); color:var(--accent-ink); } @@ -1518,13 +1529,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se field-sizing:content; } .inrow textarea::placeholder{ color:var(--faint); } .sendbtn{ width:38px; height:38px; border-radius:50%; display:grid; place-items:center; - background:var(--accent); color:#fff; flex:none; transition:.16s; } + background:var(--accent); color:var(--on-accent,#fff); flex:none; transition:.16s; } .sendbtn:disabled{ background:var(--border-strong); color:var(--faint); cursor:default; } .sendbtn svg{ width:19px; height:19px; } -.sendbtn.interrupt{ background:var(--danger); } +.sendbtn.interrupt{ background:var(--danger); color:var(--on-danger,#fff); } .hint{ display:flex; align-items:center; gap:12px; font-size:11px; color:var(--faint); padding:7px 2px 1px; } .hint-right{ position:relative; display:flex; align-items:center; gap:14px; margin-left:auto; flex:none; } -.hint-control-scope{ color:var(--faint); font-size:10px; font-weight:600; white-space:nowrap; } +.hint-control-scope{ color:var(--faint); font-size:10px; font-weight:var(--font-weight-600); white-space:nowrap; } .hint kbd{ font-family:var(--mono); font-size:10px; background:var(--raised); border:1px solid var(--border); border-radius:4px; padding:0 4px; } .hint-mode{ min-width:0; max-width:min(280px,45vw); overflow:hidden; text-overflow:ellipsis; @@ -1592,27 +1603,27 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .sheet-search svg{ width:17px; height:17px; flex:none; } .sheet-search input{ border:none; background:none; outline:none; color:var(--text); width:100%; font-size:16px; } .sheet-scroll{ overflow-y:auto; padding:6px 10px 14px; } -.sheet-title{ font-size:12.5px; font-weight:600; letter-spacing:.02em; color:var(--dim); padding:8px 20px 2px; } +.sheet-title{ font-size:12.5px; font-weight:var(--font-weight-600); letter-spacing:.02em; color:var(--dim); padding:8px 20px 2px; } .sheet-title .qa-ic{ display:inline-grid; place-items:center; width:18px; height:18px; border-radius:6px; - background:var(--accent); color:#fff; margin-right:7px; vertical-align:-3px; } + background:var(--accent); color:var(--on-accent,#fff); margin-right:7px; vertical-align:-3px; } .sheet-title .qa-ic svg{ width:13px; height:13px; } -.qa-question{ font-size:14.5px; font-weight:600; color:var(--text); line-height:1.5; padding:6px 16px 2px; white-space:pre-wrap; overflow-wrap:anywhere; } +.qa-question{ font-size:14.5px; font-weight:var(--font-weight-600); color:var(--text); line-height:1.5; padding:6px 16px 2px; white-space:pre-wrap; overflow-wrap:anywhere; } .qa-options{ display:flex; flex-direction:column; gap:8px; padding:10px 16px 16px; } .qa-opt{ display:flex; flex-direction:column; gap:3px; text-align:left; padding:11px 13px; border-radius:11px; background:var(--surface); border:1px solid var(--border); color:var(--text); transition:.14s; } .qa-opt:hover{ background:var(--accent-weak); border-color:var(--accent-line); } .qa-opt.selected{ background:var(--accent-weak); border-color:var(--accent); } -.qa-opt-label{ font-size:14px; font-weight:600; } +.qa-opt-label{ font-size:14px; font-weight:var(--font-weight-600); } .qa-opt-ds{ font-size:12.5px; color:var(--dim); } -.qa-multi-submit{ min-height:42px; border-radius:11px; background:var(--accent); color:#fff; - font-size:14px; font-weight:600; } +.qa-multi-submit{ min-height:42px; border-radius:11px; background:var(--accent); color:var(--on-accent,#fff); + font-size:14px; font-weight:var(--font-weight-600); } .qa-multi-submit:disabled{ opacity:.45; cursor:default; } -.cmd-group{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; +.cmd-group{ font-size:11px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); padding:12px 12px 5px; } .cmd-empty{ color:var(--dim); font-size:13px; padding:14px 12px 18px; } .cmd-search{ display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:5px 12px; } .cmd-search button{ min-height:38px; border:1px solid var(--border); border-radius:10px; - color:var(--dim); background:var(--surface); font-size:13px; font-weight:600; } + color:var(--dim); background:var(--surface); font-size:13px; font-weight:var(--font-weight-600); } .cmd-search button.sel{ color:var(--accent-ink); border-color:var(--accent-line); background:var(--accent-weak); } .cmd-search-note{ color:var(--faint); font-size:11.5px; line-height:1.45; @@ -1624,17 +1635,17 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se @media (hover:hover){ .cmd:hover{ background:var(--accent-weak); } } @media (hover:hover){ .cmd:disabled:hover{ background:transparent; } } .cmd-ic{ width:34px; height:34px; border-radius:10px; background:var(--bg); border:1px solid var(--border); - display:grid; place-items:center; flex:none; color:var(--accent); } + display:grid; place-items:center; flex:none; color:var(--accent-text,var(--accent)); } .cmd.sel .cmd-ic{ border-color:var(--accent-line); } @media (hover:hover){ .cmd:hover .cmd-ic{ border-color:var(--accent-line); } } .cmd-ic svg{ width:17px; height:17px; } .cmd-tx{ flex:1; min-width:0; } .cmd-nm{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; - font-weight:600; font-size:14.5px; display:flex; align-items:center; gap:8px; } -.cmd-nm .slash{ font-family:var(--mono); color:var(--accent-ink); font-weight:600; } + font-weight:var(--font-weight-600); font-size:14.5px; display:flex; align-items:center; gap:8px; } +.cmd-nm .slash{ font-family:var(--mono); color:var(--accent-ink); font-weight:var(--font-weight-600); } .cmd-ds{ font-size:12.5px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cmd-kbd{ font-family:var(--mono); font-size:11px; color:var(--faint); flex:none; width:18px; text-align:center; } -.cmd-check{ color:var(--accent); flex:none; display:grid; place-items:center; } +.cmd-check{ color:var(--accent-text,var(--accent)); flex:none; display:grid; place-items:center; } .cmd-check svg{ width:19px; height:19px; } .cmd.danger .cmd-ic{ color:var(--danger); background:var(--danger-weak); border-color:transparent; } .cmd.danger.sel{ background:var(--danger-weak); } @@ -1702,7 +1713,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .login-brand{ display:inline-flex; align-items:center; gap:11px; margin-bottom:6px; } .login-brand .dot{ width:13px; height:13px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 5px var(--accent-weak); } .login-brand .name{ font-family:var(--serif); font-size:29px; letter-spacing:-.015em; } -.login-brand .name b{ font-weight:600; } .login-brand .name span{ color:var(--dim); } +.login-brand .name b{ font-weight:var(--font-weight-600); } .login-brand .name span{ color:var(--dim); } .login-tag{ color:var(--dim); font-size:14px; margin:0 0 26px; } .login-field{ display:flex; align-items:center; gap:10px; background:var(--surface); border:1.5px solid var(--border-strong); border-radius:14px; padding:0 14px; height:52px; @@ -1720,7 +1731,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se border-radius:10px; color:var(--dim); transition:color .16s,background .16s; } .login-reveal:hover,.login-reveal[aria-pressed="true"]{ color:var(--accent-ink); background:var(--accent-weak); } .login-reveal:disabled{ opacity:.45; cursor:default; } -.login-btn{ width:100%; height:50px; background:var(--accent); color:#fff; font-weight:600; +.login-btn{ width:100%; height:50px; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:15.5px; border-radius:14px; transition:.16s; } .login-btn:hover{ filter:brightness(1.05); } .login-err{ color:var(--danger); font-size:13px; min-height:18px; margin:-4px 0 8px; } @@ -1744,7 +1755,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se gap:0; border:0; border-radius:12px; background:var(--raised); } .space-switch button{ min-height:38px; border:1px solid transparent; border-radius:9px; background:transparent; display:flex; align-items:center; justify-content:center; gap:8px; color:var(--dim); - font-size:14px; font-weight:500; transition:background .14s,color .14s,border-color .14s; } + font-size:14px; font-weight:var(--font-weight-500); transition:background .14s,color .14s,border-color .14s; } .space-switch button svg{ flex:none; } .space-switch button:hover{ color:var(--text); } .space-switch button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; } @@ -1761,16 +1772,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .profile-tone{ width:7px; height:7px; border-radius:50%; flex:none; background:var(--profile-color); } .brand{ display:flex; align-items:center; gap:10px; margin-right:auto; cursor:pointer; } .brand .dot{ width:11px; height:11px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-weak); flex:none; } -.brand-mark{ color:var(--accent); display:grid; place-items:center; flex:none; } +.brand-mark{ color:var(--accent-text,var(--accent)); display:grid; place-items:center; flex:none; } .brand .name{ font-family:var(--serif); font-size:21px; letter-spacing:-.01em; } -.brand .name b{ font-weight:600; } .brand .name span{ color:var(--dim); } +.brand .name b{ font-weight:var(--font-weight-600); } .brand .name span{ color:var(--dim); } .search{ margin:2px 18px 12px; display:flex; align-items:center; gap:9px; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:0 12px; height:42px; flex:none; color:var(--dim); } .search input{ border:none; background:none; outline:none; color:var(--text); width:100%; font-size:16px; } .search input::placeholder{ color:var(--faint); } .search svg{ width:17px; height:17px; flex:none; } .s-scroll{ flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:2px 12px 12px; position:relative; } -.s-group{ font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); padding:14px 8px 7px; } +.s-group{ font-size:11.5px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); padding:14px 8px 7px; } .scard{ display:block; width:100%; text-align:left; position:relative; padding:13px 14px; border-radius:16px; margin-bottom:0; transition:.14s; border:1px solid transparent; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-user-select:none; } .scard + .scard{ margin-top:5px; } .scard.has-profile-ribbon + .scard.has-profile-ribbon{ margin-top:7px; } @@ -1796,7 +1807,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se /* reserve room on the right for the absolute ⋯ button (top:8/right:8, 28px wide) so the "当前"/"运行" pill never sits under it. */ .scard-top{ display:flex; align-items:center; gap:9px; margin-bottom:3px; padding-right:30px; } -.scard-title{ font-weight:600; font-size:14.5px; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; text-align:left; } +.scard-title{ font-weight:var(--font-weight-600); font-size:14.5px; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; text-align:left; } @media (max-width:979px){ .scard-top.has-btw-completion{ display:grid; grid-template-columns:minmax(0,1fr) auto auto; row-gap:6px; } .scard-top.has-btw-completion .pill.completed{ grid-column:1 / -1; justify-self:start; } @@ -1812,7 +1823,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se 0 2px 5px color-mix(in srgb,var(--profile-color) 9%,transparent), inset 0 1px 0 color-mix(in srgb,#fff 58%,transparent); color:color-mix(in srgb,var(--profile-color) 70%,var(--text)); - font-family:var(--mono); font-size:8.5px; font-weight:650; line-height:1; + font-family:var(--mono); font-size:8.5px; font-weight:var(--font-weight-650); line-height:1; letter-spacing:.035em; cursor:inherit; } .tone-0{ --profile-color:#7c879a; } .tone-1{ --profile-color:#8b6fd6; } @@ -1827,14 +1838,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .scard-location span{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .scard-time{ font-size:12px; color:var(--faint); flex:none; font-variant-numeric:tabular-nums; } .scard-prev{ font-size:13px; color:var(--dim); line-height:1.45; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; text-align:left; } -.pill{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:2px 8px 2px 6px; border-radius:20px; flex:none; } +.pill{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:var(--font-weight-600); padding:2px 8px 2px 6px; border-radius:20px; flex:none; } .pill .sd{ width:6px; height:6px; border-radius:50%; } .pill.idle{ color:var(--ok); background:var(--ok-weak); } .pill.idle .sd{ background:var(--ok); } .pill.completed{ color:var(--accent-ink); background:var(--accent-weak); } .pill.completed .sd{ background:var(--accent); } .pill.offline{ color:var(--dim); background:var(--border); } .pill.offline .sd{ background:var(--faint); } .s-foot{ flex:none; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--border); background:var(--sidebar); } -.newbtn{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:#fff; font-weight:600; font-size:14.5px; padding:12px 16px; border-radius:14px; box-shadow:var(--shadow-sm); transition:.16s; } +.newbtn{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14.5px; padding:12px 16px; border-radius:14px; box-shadow:var(--shadow-sm); transition:.16s; } .newbtn:hover{ filter:brightness(1.05); } .newbtn svg{ width:19px; height:19px; } @@ -1861,7 +1872,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .s-lift-scrim{ position:absolute; inset:0; z-index:4; background:rgba(20,16,10,.42); opacity:0; pointer-events:none; transition:opacity .18s; } :root[data-theme="dark"] .s-lift-scrim{ background:rgba(0,0,0,.62); } .s-lift-scrim.show{ opacity:1; pointer-events:auto; } -.scard-rename{ flex:1; min-width:0; font:inherit; font-weight:600; font-size:16px; border:1px solid var(--accent); border-radius:7px; padding:2px 6px; background:var(--bg); color:var(--text); outline:none; user-select:text; -webkit-user-select:text; } +.scard-rename{ flex:1; min-width:0; font:inherit; font-weight:var(--font-weight-600); font-size:16px; border:1px solid var(--accent); border-radius:7px; padding:2px 6px; background:var(--bg); color:var(--text); outline:none; user-select:text; -webkit-user-select:text; } .sgroup-head{ display:flex; align-items:center; position:relative; } .sgroup-toggle{ display:flex; align-items:center; gap:7px; flex:1; min-width:0; text-align:left; padding:14px 8px 7px; } .sgroup-toggle .sgroup-icon{ transition:transform .18s; display:grid; place-items:center; color:var(--faint); flex:none; } @@ -1879,8 +1890,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .sgroup-toggle:hover~.sgroup-path-tip{ opacity:1; visibility:visible; transform:translateY(0); } } .sgroup-toggle:focus-visible~.sgroup-path-tip{ opacity:1; visibility:visible; transform:translateY(0); } -.sgroup-toggle .label{ font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.sgroup-toggle .count{ font-size:10.5px; color:var(--faint); font-weight:500; flex:none; } +.sgroup-toggle .label{ font-size:11.5px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.sgroup-toggle .count{ font-size:10.5px; color:var(--faint); font-weight:var(--font-weight-500); flex:none; } @media (min-width:980px){ /* Animate two ordinary lengths instead of grid-template-columns. Chromium can strand an interpolated 0px/1fr grid track; a fixed sidebar transform @@ -1896,7 +1907,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se } /* CONTEXT REPORT MODAL */ .ctx-overview{ padding:8px 20px 14px; } -.ctx-pct{ font-size:30px; font-weight:600; font-family:var(--serif); letter-spacing:-.02em; } +.ctx-pct{ font-size:30px; font-weight:var(--font-weight-600); font-family:var(--serif); letter-spacing:-.02em; } .ctx-bar{ height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin:10px 0 7px; } .ctx-bar-fill{ height:100%; background:var(--accent); transition:width .3s; } .ctx-numbers{ font-family:var(--mono); font-size:12px; color:var(--dim); } @@ -1917,16 +1928,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se /* SESSION GROUP "+" — sits in the header row (flex), not absolutely positioned (absolute broke when the group expanded: top:50% landed on the session cards). */ .sgroup-add{ flex:none; width:26px; height:26px; display:flex; align-items:center; justify-content:center; color:var(--dim); border-radius:50%; opacity:.85; transition:opacity .14s, background .14s, color .14s; } -.sgroup-add:hover{ opacity:1; color:var(--accent); background:var(--accent-weak); } +.sgroup-add:hover{ opacity:1; color:var(--accent-text,var(--accent)); background:var(--accent-weak); } .sgroup-add svg{ width:14px; height:14px; } /* NEW CHAT welcome page (centered composer a la Claude app / Codex) */ .newchat{ flex:1; display:flex; align-items:center; justify-content:center; padding:24px 20px; overflow-y:auto; } .newchat-card{ width:100%; max-width:560px; display:flex; flex-direction:column; gap:14px; } -.newchat-greet{ font-size:22px; font-weight:600; font-family:var(--serif); color:var(--text); text-align:center; letter-spacing:-.01em; } +.newchat-greet{ font-size:22px; font-weight:var(--font-weight-600); font-family:var(--serif); color:var(--text); text-align:center; letter-spacing:-.01em; } .work-private-note{ display:flex; align-items:center; justify-content:center; gap:7px; align-self:center; max-width:520px; color:var(--dim); font-size:12px; line-height:1.5; text-align:center; } -.work-private-note svg{ flex:none; color:var(--accent); } +.work-private-note svg{ flex:none; color:var(--accent-text,var(--accent)); } .work-project-bar{ display:flex; align-items:center; justify-content:center; gap:8px; flex-wrap:wrap; } .work-project-bar select,.work-project-bar button{ min-height:36px; padding:7px 11px; border:1px solid var(--border); border-radius:10px; background:var(--raised); color:var(--text); font-size:12.5px; } @@ -1938,14 +1949,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se border:1px solid var(--border); border-radius:13px; background:var(--raised); color:var(--dim); text-align:left; transition:background .14s,border-color .14s,color .14s,transform .14s; } .work-starters button:hover{ color:var(--text); border-color:var(--accent-line); background:var(--surface); transform:translateY(-1px); } -.work-starters button svg{ color:var(--accent); flex:none; } +.work-starters button svg{ color:var(--accent-text,var(--accent)); flex:none; } .newchat-context{ display:flex; align-items:center; justify-content:center; align-self:center; gap:8px; width:100%; max-width:100%; flex-wrap:wrap; } .newchat-profile{ display:flex; align-items:center; gap:6px; max-width:100%; min-height:34px; padding:5px 7px 5px 10px; border:1px solid var(--border); border-radius:999px; background:var(--raised); color:var(--faint); font-size:11px; } .newchat-profile > b{ min-width:0; max-width:180px; - color:var(--text); font:600 12px/1.2 var(--sans); } + color:var(--text); font:var(--font-weight-600) 12px/1.2 var(--sans); } .newchat-profile:focus-within{ border-color:var(--accent-line); } .newchat-profile-error{ display:flex; align-items:center; justify-content:center; gap:6px; flex-basis:100%; color:var(--danger); font-size:11.5px; line-height:1.4; text-align:center; } @@ -1974,7 +1985,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se text-overflow:ellipsis; white-space:nowrap; } .newchat-access:hover{ border-color:var(--border-strong); color:var(--text); background:var(--surface); } .newchat-access:disabled{ opacity:.55; cursor:default; } -.newchat-send{ display:flex; align-items:center; gap:7px; background:var(--accent); color:#fff; font-weight:600; font-size:14px; padding:10px 18px; border-radius:12px; transition:filter .14s, opacity .14s; } +.newchat-send{ display:flex; align-items:center; gap:7px; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14px; padding:10px 18px; border-radius:12px; transition:filter .14s, opacity .14s; } .newchat-send:hover{ filter:brightness(1.05); } .newchat-send:disabled{ opacity:.45; cursor:not-allowed; } .newchat-send svg{ width:16px; height:16px; } @@ -1988,10 +1999,10 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se border-radius:20px; background:var(--surface); box-shadow:var(--shadow-lg); } .work-manager>header{ display:flex; align-items:center; gap:12px; padding:16px 18px 12px; border-bottom:1px solid var(--border); } .work-manager>header>div{ display:flex; flex-direction:column; gap:2px; flex:1; min-width:0; } -.work-manager>header span{ font:600 20px/1.2 var(--serif); } +.work-manager>header span{ font:var(--font-weight-600) 20px/1.2 var(--serif); } .work-manager>header small{ color:var(--dim); font-size:11.5px; } .work-manager>nav{ display:flex; gap:4px; padding:8px 12px; border-bottom:1px solid var(--border); overflow-x:auto; } -.work-manager>nav button{ flex:none; padding:8px 13px; border-radius:9px; color:var(--dim); font-size:13px; font-weight:600; } +.work-manager>nav button{ flex:none; padding:8px 13px; border-radius:9px; color:var(--dim); font-size:13px; font-weight:var(--font-weight-600); } .work-manager>nav button.active{ color:var(--accent-ink); background:var(--accent-weak); } .work-manager-body{ flex:1; min-height:0; overflow-y:auto; padding:16px; display:grid; align-content:start; gap:14px; } .work-artifacts-scrim{ z-index:49; } @@ -2002,7 +2013,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .work-artifacts-sheet>header{ display:flex; align-items:center; gap:12px; padding:14px 14px 11px 16px; border-bottom:1px solid var(--border); } .work-artifacts-sheet>header>div{ display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; } -.work-artifacts-sheet>header span{ font:600 18px/1.2 var(--serif); } +.work-artifacts-sheet>header span{ font:var(--font-weight-600) 18px/1.2 var(--serif); } .work-artifacts-sheet>header small{ color:var(--dim); font-size:11.5px; } .work-artifacts-list{ min-height:0; overflow-y:auto; overscroll-behavior:contain; padding:8px; } .work-artifacts-list>button{ display:flex; width:100%; align-items:center; gap:10px; min-height:62px; padding:9px 10px; @@ -2014,7 +2025,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se color:var(--accent-ink); background:var(--accent-weak); } .work-artifact-main{ display:flex; flex:1; min-width:0; flex-direction:column; } .work-artifact-main b{ overflow:hidden; color:var(--text); font-size:12.5px; text-overflow:ellipsis; white-space:nowrap; } -.work-artifact-main small{ overflow:hidden; color:var(--faint); font:10px/1.4 var(--mono); text-overflow:ellipsis; white-space:nowrap; } +.work-artifact-main small{ overflow:hidden; color:var(--faint); font:var(--font-weight-400) 10px/1.4 var(--mono); text-overflow:ellipsis; white-space:nowrap; } .work-artifact-meta{ display:flex; flex:none; flex-direction:column; align-items:flex-end; } .work-artifact-meta b{ color:var(--dim); font-size:10.5px; } .work-artifact-meta small,.work-artifact-unavailable{ color:var(--faint); font-size:10px; white-space:nowrap; } @@ -2026,7 +2037,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .work-form input:focus,.work-form textarea:focus,.work-form select:focus,.work-project-picker:focus{ border-color:var(--accent-line); } .work-schedule-profile{ display:grid; gap:6px; color:var(--dim); font-size:11.5px; } .work-form>.primary,.work-form-actions>button,.file-action{ justify-self:start; padding:9px 14px; border-radius:10px; - background:var(--accent); color:#fff; font-size:13px; font-weight:650; cursor:pointer; } + background:var(--accent); color:var(--on-accent,#fff); font-size:13px; font-weight:var(--font-weight-650); cursor:pointer; } .work-form button:disabled{ opacity:.45; cursor:not-allowed; } .work-form small{ color:var(--dim); font-size:11.5px; line-height:1.5; } .work-form-actions{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; } @@ -2037,8 +2048,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se transition:border-color .14s,box-shadow .14s,background .14s; } .date-time-trigger:hover,.date-time-trigger:focus-visible{ border-color:var(--accent-line); box-shadow:0 0 0 3px var(--accent-weak); } .date-time-trigger>span:nth-child(2){ display:flex; min-width:0; flex:1; flex-direction:column; gap:1px; } -.date-time-trigger small{ color:var(--faint); font-size:10px; font-weight:600; letter-spacing:.04em; } -.date-time-trigger b{ overflow:hidden; font-size:12.5px; font-weight:560; text-overflow:ellipsis; white-space:nowrap; } +.date-time-trigger small{ color:var(--faint); font-size:10px; font-weight:var(--font-weight-600); letter-spacing:.04em; } +.date-time-trigger b{ overflow:hidden; font-size:12.5px; font-weight:var(--font-weight-560); text-overflow:ellipsis; white-space:nowrap; } .date-time-trigger.has-value b{ color:var(--text); } .date-time-trigger-icon{ width:30px; height:30px; flex:none; display:grid; place-items:center; border-radius:9px; background:var(--accent-weak); color:var(--accent-ink); } @@ -2058,25 +2069,25 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .date-time-quick button:hover{ color:var(--accent-ink); border-color:var(--accent-line); background:var(--accent-weak); } .date-time-week,.date-time-days{ display:grid; grid-template-columns:repeat(7,1fr); gap:3px; } .date-time-week{ margin-bottom:3px; } -.date-time-week span{ padding:3px 0; color:var(--faint); font-size:10px; font-weight:650; text-align:center; } +.date-time-week span{ padding:3px 0; color:var(--faint); font-size:10px; font-weight:var(--font-weight-650); text-align:center; } .date-time-days button{ height:32px; min-width:0; display:grid; place-items:center; border:1px solid transparent; border-radius:9px; color:var(--dim); font-size:11.5px; font-variant-numeric:tabular-nums; } .date-time-days button:hover{ color:var(--text); background:var(--raised); } .date-time-days button.outside{ color:var(--faint); opacity:.55; } .date-time-days button.today{ border-color:var(--accent-line); color:var(--accent-ink); } -.date-time-days button.selected{ border-color:var(--accent); background:var(--accent); color:#fff; font-weight:700; } +.date-time-days button.selected{ border-color:var(--accent); background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-700); } .date-time-clock{ display:flex; align-items:center; gap:7px; margin-top:11px; padding:10px; border-radius:12px; background:var(--raised); } -.date-time-clock>span{ display:flex; align-items:center; gap:6px; margin-right:auto; color:var(--dim); font-size:11px; font-weight:650; } +.date-time-clock>span{ display:flex; align-items:center; gap:6px; margin-right:auto; color:var(--dim); font-size:11px; font-weight:var(--font-weight-650); } .date-time-clock label{ position:relative; } .date-time-clock label>span{ position:absolute; top:3px; left:8px; color:var(--faint); font-size:8px; pointer-events:none; } .date-time-clock select{ width:62px; min-width:0; height:42px; padding:13px 7px 3px; border:1px solid var(--border); - border-radius:9px; background:var(--surface); color:var(--text); font:650 13px/1 var(--mono); text-align:center; } -.date-time-clock i{ color:var(--faint); font-style:normal; font-weight:700; } + border-radius:9px; background:var(--surface); color:var(--text); font:var(--font-weight-650) 13px/1 var(--mono); text-align:center; } +.date-time-clock i{ color:var(--faint); font-style:normal; font-weight:var(--font-weight-700); } .date-time-popover>footer{ display:flex; align-items:center; gap:5px; margin-top:10px; padding-top:10px; border-top:1px solid var(--border); } .date-time-popover>footer span{ flex:1; } -.date-time-popover>footer button{ padding:7px 10px; border-radius:9px; color:var(--dim); font-size:11.5px; font-weight:600; } +.date-time-popover>footer button{ padding:7px 10px; border-radius:9px; color:var(--dim); font-size:11.5px; font-weight:var(--font-weight-600); } .date-time-popover>footer button:hover{ background:var(--raised); color:var(--text); } -.date-time-popover>footer button.primary{ background:var(--accent); color:#fff; } +.date-time-popover>footer button.primary{ background:var(--accent); color:var(--on-accent,#fff); } .date-time-popover>footer button.date-time-clear{ color:var(--danger); } .work-items{ display:grid; gap:7px; } .work-items article{ display:flex; align-items:flex-start; gap:12px; padding:12px 13px; border:1px solid var(--border); @@ -2109,7 +2120,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se :root[data-theme="dark"] .dp-overlay{ background:rgba(0,0,0,.62); } .dp{ width:100%; max-width:460px; max-height:min(80vh,calc(var(--app-height,100dvh) - 40px)); background:var(--surface); border:1px solid var(--border); border-radius:18px; box-shadow:var(--shadow-lg); display:flex; flex-direction:column; overflow:hidden; } .dp-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 16px 8px; } -.dp-title{ font-size:15px; font-weight:600; color:var(--text); } +.dp-title{ font-size:15px; font-weight:var(--font-weight-600); color:var(--text); } .dp-crumbs{ padding:4px 16px 10px; font-size:12px; font-family:var(--mono); color:var(--dim); word-break:break-all; line-height:1.4; } .dp-list{ flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:0 8px 8px; } .dp-row{ display:flex; align-items:center; gap:10px; width:100%; text-align:left; padding:11px 10px; border-radius:10px; color:var(--text); font-size:14px; transition:background .12s; } @@ -2122,7 +2133,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se .dp-input{ width:100%; background:var(--raised); border:1px solid var(--border); border-radius:10px; padding:10px 12px; font-size:13px; font-family:var(--mono); color:var(--text); outline:none; } .dp-input:focus{ border-color:var(--accent-line); } .dp-error{ color:var(--danger); font-size:12px; line-height:1.4; } -.dp-confirm{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:#fff; font-weight:600; font-size:14px; padding:12px; border-radius:12px; transition:filter .14s, opacity .14s; } +.dp-confirm{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14px; padding:12px; border-radius:12px; transition:filter .14s, opacity .14s; } .dp-confirm:hover{ filter:brightness(1.05); } .dp-confirm:disabled{ opacity:.45; cursor:not-allowed; } .dp-confirm svg{ width:15px; height:15px; } @@ -2130,3 +2141,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se @media (max-width:540px){ .dp{ max-width:100%; max-height:min(88vh,calc(var(--app-height,100dvh) - 24px)); border-radius:16px; } } + +/* Palette surfaces stay tinted when scrolling controls float over the chat. */ +:root[data-palette]:not([data-palette="classic"]) .scroll-bottom-btn{ background:color-mix(in srgb,var(--surface) 92%,transparent); } +:root[data-palette]:not([data-palette="classic"]) :focus-visible{ outline-color:var(--accent-ink); } diff --git a/web/src/themes.css b/web/src/themes.css new file mode 100644 index 00000000..3e333323 --- /dev/null +++ b/web/src/themes.css @@ -0,0 +1,133 @@ +/* Approved low-saturation palettes. The miniature cards and the application + share these tokens; engine-specific classic skins remain the default. */ +:root[data-engine][data-theme][data-palette="amber"], +[data-preview-palette="amber"] { + --bg:#F6F4EF; + --surface:#FCFAF6; + --sidebar:#EDE8DF; + --raised:#F0ECE5; + --text:#302D28; + --dim:#696054; + --faint:#847B6F; + --border:#DED8CE; + --border-strong:#CEC4B6; + --divider:#E6E0D7; + --accent:#AA9982; + --accent-ink:#756048; + --accent-weak:#EBE2D4; + --accent-line:#CEBEA8; + --on-accent:#29241E; +} + +:root[data-engine][data-theme][data-palette="moss"], +[data-preview-palette="moss"] { + --bg:#F2F4EF; + --surface:#F8F9F5; + --sidebar:#E7EBE2; + --raised:#EBEFE6; + --text:#2C312A; + --dim:#5D6856; + --faint:#77816F; + --border:#D6DDD0; + --border-strong:#C3CEBA; + --divider:#E0E5DA; + --accent:#96A28B; + --accent-ink:#55654A; + --accent-weak:#E0E7D9; + --accent-line:#BCC9B1; + --on-accent:#232C1F; +} + +:root[data-engine][data-theme][data-palette="rose"], +[data-preview-palette="rose"] { + --bg:#F6F1F2; + --surface:#FCF8F9; + --sidebar:#EFE5E8; + --raised:#F1E9EC; + --text:#332D30; + --dim:#705F67; + --faint:#88757F; + --border:#E1D5DA; + --border-strong:#D3C0C8; + --divider:#E9DFE3; + --accent:#B69BA5; + --accent-ink:#795566; + --accent-weak:#EBDDE1; + --accent-line:#D3B8C2; + --on-accent:#30222A; +} + +:root[data-engine][data-theme][data-palette="lagoon"], +[data-preview-palette="lagoon"] { + --bg:#242B2D; + --surface:#2E383B; + --sidebar:#202729; + --raised:#333F42; + --text:#E2E6E5; + --dim:#ABB9BA; + --faint:#94A3A5; + --border:#414D50; + --border-strong:#536266; + --divider:#354144; + --accent:#9AB1B3; + --accent-ink:#B1C7C9; + --accent-weak:#364447; + --accent-line:#5C7377; + --on-accent:#202729; +} + +:root[data-engine][data-theme][data-palette="bordeaux"], +[data-preview-palette="bordeaux"] { + --bg:#2D282B; + --surface:#3A3338; + --sidebar:#262225; + --raised:#40373D; + --text:#E8E2E5; + --dim:#B8ACB2; + --faint:#A999A2; + --border:#4B4147; + --border-strong:#64535D; + --divider:#3D343A; + --accent:#B7A0AA; + --accent-ink:#CFB7C2; + --accent-weak:#463B42; + --accent-line:#7A606E; + --on-accent:#292126; +} + +:root[data-engine][data-theme][data-palette="heritage"], +[data-preview-palette="heritage"] { + --bg:#272D28; + --surface:#343D35; + --sidebar:#222824; + --raised:#3A443B; + --text:#E1E6DE; + --dim:#B2BDAC; + --faint:#9EAE97; + --border:#475146; + --border-strong:#5A6956; + --divider:#394238; + --accent:#AAB79F; + --accent-ink:#C2CFB8; + --accent-weak:#404A40; + --accent-line:#697961; + --on-accent:#222824; +} + +:root[data-engine][data-theme][data-palette]:not([data-palette="classic"]) { + --accent-text:var(--accent-ink); +} +:root[data-palette]:not([data-palette="classic"])[data-theme="light"] { + --ok:#4A7158; --ok-weak:#E4EBE4; --warn:#856623; --warn-weak:#EDE6D8; + --danger:#A44F49; --danger-weak:#F0E1DF; --on-danger:#FFFFFF; + --shadow-sm:0 1px 2px rgb(40 40 40 / .08); + --shadow:0 8px 28px -12px rgb(40 40 40 / .18); + --shadow-lg:0 18px 54px -14px rgb(40 40 40 / .24); +} +:root[data-palette]:not([data-palette="classic"])[data-theme="dark"] { + --ok:#A1BAA7; --ok-weak:#344239; --warn:#C9B88E; --warn-weak:#443F33; + --danger:#D3A29D; --danger-weak:#48383A; --on-danger:#292126; + --shadow-sm:0 1px 2px rgb(0 0 0 / .08); + --shadow:0 8px 28px -12px rgb(0 0 0 / .18); + --shadow-lg:0 18px 54px -14px rgb(0 0 0 / .55); +} diff --git a/web/src/themes.ts b/web/src/themes.ts new file mode 100644 index 00000000..32f9fe81 --- /dev/null +++ b/web/src/themes.ts @@ -0,0 +1,91 @@ +/** Appearance is local to each engine. Only the resolved light/dark mode is + * passed to diff/preview renderers; palette names never enter the protocol. */ +export type ThemeEngine = "claude" | "codex" | "dsh"; +export type ThemeMode = "light" | "dark"; +export type PaletteId = "amber" | "moss" | "rose" | "lagoon" | "bordeaux" | "heritage"; +export type ThemeChoice = "system" | ThemeMode | PaletteId; +export type ThemePreferences = Partial>; +export const THEME_STORAGE_KEY = "cc_remote_themes_v1"; +export const LEGACY_THEME_KEY = "cc_remote_theme"; + +export interface ThemePalette { + id: PaletteId; + name: string; + description: string; + mode: ThemeMode; +} + +export const THEME_PALETTES: readonly ThemePalette[] = [ + { + id: "amber", name: "奶油琥珀", description: "燕麦白 · 暖灰褐", mode: "light", + }, + { + id: "moss", name: "森林苔绿", description: "雾白 · 鼠尾草灰", mode: "light", + }, + { + id: "rose", name: "玫瑰雾", description: "柔白 · 玫瑰灰", mode: "light", + }, + { + id: "lagoon", name: "深海青", description: "炭灰 · 雾青", mode: "dark", + }, + { + id: "bordeaux", name: "酒红夜色", description: "暖炭灰 · 烟粉", mode: "dark", + }, + { + id: "heritage", name: "复古终端绿", description: "石墨灰 · 灰绿", mode: "dark", + }, +]; + +export function themePalette(choice: ThemeChoice): ThemePalette | undefined { + return THEME_PALETTES.find(palette => palette.id === choice); +} + +export function isThemeChoice(value: unknown): value is ThemeChoice { + return value === "system" || value === "light" || value === "dark" + || THEME_PALETTES.some(palette => palette.id === value); +} + +export function themeLabel(choice: ThemeChoice): string { + return themePalette(choice)?.name ?? (choice === "system" ? "跟随系统" : choice === "dark" ? "经典深色" : "经典浅色"); +} + +export function resolveThemeMode(choice: ThemeChoice, systemDark: boolean): ThemeMode { + return themePalette(choice)?.mode ?? (choice === "dark" || (choice === "system" && systemDark) ? "dark" : "light"); +} + +export function readThemePreferences(storage?: Pick): ThemePreferences { + try { + const legacy = storage?.getItem(LEGACY_THEME_KEY); + const fallback: ThemeChoice = legacy === "light" || legacy === "dark" ? legacy : "system"; + let saved: unknown; + try { saved = JSON.parse(storage?.getItem(THEME_STORAGE_KEY) ?? "null"); } + catch { /* Fall back to the previous light/dark preference. */ } + const preferences: ThemePreferences = {}; + for (const engine of ["claude", "codex", "dsh"] as const) { + const value = saved && typeof saved === "object" ? (saved as Record)[engine] : undefined; + preferences[engine] = isThemeChoice(value) ? value : fallback; + } + return preferences; + } catch { return {}; /* Private browsing may disable storage entirely. */ } +} + +/** Merge the latest persisted map so another tab's engine choice is retained. */ +export function saveThemeChoice(storage: Pick | undefined, + current: ThemePreferences, engine: ThemeEngine, choice: ThemeChoice): ThemePreferences { + let next = { ...current, [engine]: choice }; + try { + if (storage) { + next = { ...readThemePreferences(storage), ...current, [engine]: choice }; + // Only the selected engine is ours to overwrite across tabs. + let saved: Partial> | null = null; + try { saved = JSON.parse(storage.getItem(THEME_STORAGE_KEY) ?? "null"); } + catch { /* Replace malformed preferences with this valid selection. */ } + for (const other of ["claude", "codex", "dsh"] as const) { + const value = saved?.[other]; + if (other !== engine && isThemeChoice(value)) next[other] = value; + } + storage.setItem(THEME_STORAGE_KEY, JSON.stringify(next)); + } + } catch { /* Selection remains usable in memory when storage is unavailable. */ } + return next; +} diff --git a/web/src/typography.css b/web/src/typography.css new file mode 100644 index 00000000..c981e041 --- /dev/null +++ b/web/src/typography.css @@ -0,0 +1,27 @@ +/* Use these weights for app text, including font shorthands, so the reading + preference also reaches lazy dialogs. The normal scale preserves existing + typography exactly; bold text raises each weight without flattening it. + Embedded previews keep their own document and typography. */ +:root{ + --font-weight-400:400; + --font-weight-500:500; + --font-weight-550:550; + --font-weight-560:560; + --font-weight-600:600; + --font-weight-650:650; + --font-weight-700:700; + --font-weight-750:750; +} +:root[data-bold-text="true"]{ + --font-weight-400:600; + --font-weight-500:700; + --font-weight-550:750; + --font-weight-560:760; + --font-weight-600:800; + --font-weight-650:850; + --font-weight-700:900; + --font-weight-750:950; +} + +body,select{font-weight:var(--font-weight-400)} +h1,h2,h3,h4,h5,h6,th{font-weight:var(--font-weight-700)} diff --git a/web/src/use-bold-text.ts b/web/src/use-bold-text.ts new file mode 100644 index 00000000..58fbc191 --- /dev/null +++ b/web/src/use-bold-text.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; + +const STORAGE_KEY = "cc_remote_bold_text"; + +function readBoldText(): boolean { + try { return window.localStorage.getItem(STORAGE_KEY) === "1"; } + catch { return false; } +} + +/** A browser-wide reading preference, shared by every engine and theme. */ +export function useBoldText() { + const [boldText, setValue] = useState(readBoldText); + + useLayoutEffect(() => { + document.documentElement.dataset.boldText = String(boldText); + }, [boldText]); + + useEffect(() => { + const update = (event: StorageEvent) => { + if (event.key === STORAGE_KEY || event.key === null) setValue(readBoldText()); + }; + window.addEventListener("storage", update); + return () => window.removeEventListener("storage", update); + }, []); + + const setBoldText = useCallback((enabled: boolean) => { + setValue(enabled); + // Opaque preview frames and storage-blocked browsers still update in memory. + try { window.localStorage.setItem(STORAGE_KEY, enabled ? "1" : "0"); } + catch { /* This setting remains usable for the current page. */ } + }, []); + + return { boldText, setBoldText }; +} diff --git a/web/src/use-theme.ts b/web/src/use-theme.ts new file mode 100644 index 00000000..76be53da --- /dev/null +++ b/web/src/use-theme.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { + LEGACY_THEME_KEY, THEME_STORAGE_KEY, readThemePreferences, + resolveThemeMode, saveThemeChoice, themePalette, + type ThemeChoice, type ThemeEngine, +} from "./themes"; + +function browserStorage(): Storage | undefined { + try { return typeof window === "undefined" ? undefined : window.localStorage; } + catch { return undefined; } +} + +export function useTheme(engine: ThemeEngine) { + const [preferences, setPreferences] = useState(() => readThemePreferences(browserStorage())); + const [systemDark, setSystemDark] = useState(() => typeof window !== "undefined" + && typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches); + const choice = preferences[engine] ?? "system"; + const mode = resolveThemeMode(choice, systemDark); + + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const update = () => setSystemDark(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + useEffect(() => { + const update = (event: StorageEvent) => { + if (event.key === THEME_STORAGE_KEY || event.key === LEGACY_THEME_KEY || event.key === null) { + setPreferences(readThemePreferences(browserStorage())); + } + }; + window.addEventListener("storage", update); + return () => window.removeEventListener("storage", update); + }, []); + + useLayoutEffect(() => { + const root = document.documentElement; + root.dataset.engine = engine; + root.dataset.theme = mode; + const palette = themePalette(choice); + root.dataset.palette = palette?.id ?? "classic"; + root.style.colorScheme = mode; + const background = getComputedStyle(root).getPropertyValue("--bg").trim(); + document.querySelectorAll('meta[name="theme-color"]').forEach(meta => { + meta.content = background; + }); + }, [engine, choice, mode]); + + const selectTheme = useCallback((next: ThemeChoice) => { + // Write only on a user selection, never on mount or a system appearance change. + setPreferences(current => saveThemeChoice(browserStorage(), current, engine, next)); + }, [engine]); + return { choice, mode, selectTheme }; +} diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 4d8b1220..08ee426c 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -59,6 +59,7 @@ import { } from "../src/components/QueuedQueryDialog"; import { DirPicker } from "../src/components/DirPicker"; import { HeaderMenu } from "../src/components/HeaderMenu"; +import { useBoldText } from "../src/use-bold-text"; import { UsageActivitySheet } from "../src/components/UsageActivitySheet"; import { displayHistoryProjection } from "../src/history-recovery"; import { summaryHistoryTurns } from "../src/history-summary"; @@ -536,6 +537,7 @@ function UsageActivityBrowserFixture({ engine: "claude" | "codex"; }) { const [activityOpen, setActivityOpen] = useState(false); + const { boldText, setBoldText } = useBoldText(); const report = useMemo(fixtureUsageReport, []); useEffect(() => { document.documentElement.dataset.engine = engine; @@ -546,12 +548,14 @@ function UsageActivityBrowserFixture({ true} onOpenUsageActivity={() => setActivityOpen(true)} - onToggleTheme={() => {}} + onSelectTheme={() => {}} onLogout={() => {}} /> diff --git a/web/tests/reliability.test.ts b/web/tests/reliability.test.ts index 31a93c62..86ce8218 100644 --- a/web/tests/reliability.test.ts +++ b/web/tests/reliability.test.ts @@ -1591,7 +1591,7 @@ assert.match(layoutCss, /\.scard-profile-ribbon\s*\{[^}]*top\s*:\s*-6px[^}]*height\s*:\s*16px[^}]*max-width\s*:\s*64px[^}]*font-family\s*:\s*var\(--mono\)[^}]*font-size\s*:\s*8\.5px/s, "profile keycaps must hang compactly from the card edge"); assert.match(layoutCss, - /\.work-profile-owner\s*\{[^}]*max-width\s*:\s*72px[^}]*height\s*:\s*19px[^}]*font\s*:\s*650 9px\/1 var\(--mono\)/s, + /\.work-profile-owner\s*\{[^}]*max-width\s*:\s*72px[^}]*height\s*:\s*19px[^}]*font\s*:\s*var\(--font-weight-650\) 9px\/1 var\(--mono\)/s, "a multi-account Work owner stays compact in the shared header"); assert.match(layoutCss, /@media \(max-width:980px\)\{\s*\.artifact-panel\{[^}]*top:calc\(var\(--app-offset-top,0px\) \+ 10px\)[^}]*bottom:auto[^}]*height:calc\(var\(--app-height,100dvh\) - 20px\)[^}]*max-height:none/s, diff --git a/web/tests/remote-viewer-theme.spec.ts b/web/tests/remote-viewer-theme.spec.ts new file mode 100644 index 00000000..545c9bf9 --- /dev/null +++ b/web/tests/remote-viewer-theme.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +// Run against the exported bundle, through the real Relay/Wrapper resource +// channel and opaque frame. A Vite dev-page test cannot catch TSX/asset failures. +test("exported theme preview loads and switches palettes over Bridge", async ({ page }, info) => { + test.skip(process.env.VIEWER_TEST_KIND !== "theme", "Supply a built theme preview."); + test.setTimeout(120000); + const failures: string[] = []; + const reads: string[] = []; + page.on("pageerror", error => failures.push(error.message)); + page.on("websocket", socket => { + if (!socket.url().endsWith("/ws/viewer-client")) return; + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string") return; + const message = JSON.parse(payload); + if (message.type === "read") reads.push(message.path); + }); + }); + await page.goto(`${process.env.VIEWER_TEST_ORIGIN ?? "http://127.0.0.1:4174"}/tests/remote-viewer.html`); + expect(await page.evaluate(async () => (await fetch("/api/login", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "local-viewer-fixture-password" }), + })).status)).toBe(200); + await page.getByRole("button", { name: "打开远程预览" }).click(); + await page.getByRole("button", { name: /^机器人结构/ }).click(); + const frame = page.frameLocator(".viewer-stage iframe"); + await expect(frame.locator(".shell")).toBeVisible({ timeout: 30000 }); + await expect(frame.getByText("主题预览 · 示例会话", { exact: true })).toBeAttached(); + await expect(page.locator(".viewer-error-banner")).toHaveCount(0); + await expect(page.locator(".viewer-stage iframe")).toHaveAttribute("sandbox", "allow-scripts"); + const draft = frame.locator(".inrow textarea"); + await draft.fill("手机预览保留草稿"); + await frame.getByRole("button", { name: "更多设置", exact: true }).click(); + await frame.getByRole("button", { name: /^主题/ }).click(); + const dialog = frame.getByRole("dialog", { name: "主题", exact: true }); + await expect(dialog.locator(".theme-card")).toHaveCount(6); + for (const [name, id] of [["奶油琥珀", "amber"], ["森林苔绿", "moss"], ["玫瑰雾", "rose"], + ["深海青", "lagoon"], ["酒红夜色", "bordeaux"], ["复古终端绿", "heritage"]]) { + await dialog.getByRole("button", { name, exact: true }).click(); + await expect(frame.locator("html")).toHaveAttribute("data-palette", id); + await expect(draft).toHaveValue("手机预览保留草稿"); + } + await dialog.getByRole("button", { name: "森林苔绿", exact: true }).click(); + await page.screenshot({ path: info.outputPath("theme-preview-bridge.png"), fullPage: true }); + await dialog.getByRole("button", { name: "完成", exact: true }).click(); + await page.getByRole("button", { name: "刷新预览", exact: true }).click(); + await expect(frame.locator(".shell")).toBeVisible({ timeout: 30000 }); + await expect(page.locator(".viewer-error-banner")).toHaveCount(0); + expect(reads.some(path => path.endsWith(".js"))).toBe(true); + // A huge lazy graph passes on loopback but times out over a mobile relay. + expect(new Set(reads).size).toBeLessThanOrEqual(6); + expect(reads.filter(path => /\.[jt]sx?(?:$|\?)/.test(path) && !path.endsWith(".js"))).toEqual([]); + expect(failures).toEqual([]); +}); diff --git a/web/tests/theme-preview.fixture.tsx b/web/tests/theme-preview.fixture.tsx new file mode 100644 index 00000000..e55a8ad9 --- /dev/null +++ b/web/tests/theme-preview.fixture.tsx @@ -0,0 +1,118 @@ +/** Isolated, zero-network preview using the production chat and controls. */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import "../src/index.css"; +import "../src/App.css"; +import { SessionsSidebar } from "../src/components/SessionsSidebar"; +import { ChatView } from "../src/components/ChatView"; +import { Composer } from "../src/components/Composer"; +import { HeaderMenu } from "../src/components/HeaderMenu"; +import { EngineSelector } from "../src/components/EngineSelector"; +import BackgroundTaskControl from "../src/components/BackgroundTaskControl"; +import { ComposerDraftStore } from "../src/composer-drafts"; +import { Icon } from "../src/icons"; +import { createRuntime, initialState, reduce, type AppState } from "../src/reducer"; +import { normalizeEngine, PROTOCOL_VERSION, type Engine, type ServerEvent, type SessionInfo } from "../src/protocol"; +import { isThemeChoice } from "../src/themes"; +import { useTheme } from "../src/use-theme"; +import { useBoldText } from "../src/use-bold-text"; +import { useMobileViewport } from "../src/use-mobile-viewport"; + +const params = new URLSearchParams(location.search); +const noop = () => {}; +const profiles = [{ id: "default", label: "Primary", available: true }, { id: "nyx", label: "Stack", available: true }]; +function demoState(engine: Engine) { + let state: AppState = { ...initialState, runtimes: { preview: createRuntime() } }; + const events: Record[] = [ + { type: "user_msg", msg_id: "theme-preview", prompt: "试试这套低饱和主题,看看聊天、代码和后台任务的效果。" }, + { type: "state", state: "running" }, + { type: "process", item_id: "thinking", kind: "reasoning", phase: "end", status: "succeeded", + turn_id: "theme-preview", title: "检查配色与文字对比度", text: "让主题色留在背景和控件中,保持正文清晰。" }, + { type: "process", item_id: "read", kind: "tool", phase: "end", status: "succeeded", + turn_id: "theme-preview", title: "读取主题配置", tool_name: "Read", command: "web/src/themes.ts" }, + { type: "assistant_msg_start", message_id: "answer", turn_id: "theme-preview", channel: "final" }, + { type: "delta", message_id: "answer", turn_id: "theme-preview", channel: "final", text: + "六款主题已经准备好了,页面保留了原来的布局。\n\n### 更柔和,也更清楚\n\n背景、消息气泡、边框和按钮使用同一套配色,正文保留清晰的对比。\n\n```ts\nconst appearance = {\n palette: 'moss',\n rememberPerEngine: true,\n};\n```\n\n从右上角 **更多设置 → 主题** 打开配色卡,点击就能看到整页效果。每个引擎的选择会在当前浏览器单独保存。" }, + { type: "assistant_msg_end", message_id: "answer", turn_id: "theme-preview", channel: "final" }, + { type: "turn_end", turn_id: "theme-preview", result: { subtype: "success", duration_ms: 74000, is_error: false } }, + { type: "state", state: "idle" }, + { type: "background_process_sync", items: [{ item_id: "build", kind: "task", status: "running", + title: "检查主题预览", command: "npm run build", started_at: Date.now() / 1000 - 90 }] }, + ]; + for (const payload of events) state = reduce(state, { type: "event", event: { + v: PROTOCOL_VERSION, ts: Date.now() / 1000, sid: "preview", engine, ...payload, + } as ServerEvent }); + return state.runtimes.preview; +} + +export function ThemePreview() { + useMobileViewport(); + const [engine, setEngine] = useState(() => normalizeEngine(params.get("engine") ?? "codex")); + const { mode, choice, selectTheme } = useTheme(engine); + const { boldText, setBoldText } = useBoldText(); + const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= 980); + const [active, setActive] = useState("preview"); + const [space, setSpace] = useState<"code" | "work">("code"); + const [sendMode, setSendMode] = useState<"steer" | "queue">("steer"); + const drafts = useRef(new ComposerDraftStore()); + const runtime = useMemo(() => demoState(engine), [engine]); + useEffect(() => { + const palette = params.get("palette"); + if (isThemeChoice(palette)) { + selectTheme(palette); + const url = new URL(location.href); + url.searchParams.delete("palette"); + history.replaceState(null, "", url); + } + // Preview URL initializes a palette once; UI selections remain live afterward. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const sessions: SessionInfo[] = [ + { session_id: "preview", summary: "cc-remote", cwd: "/workspace/cc-remote", pinned: true, state: "idle" }, + { session_id: "auriga", summary: "Auriga", cwd: "/workspace/auriga", pinned: true, state: "idle" }, + { session_id: "speech", summary: "机器人语音", cwd: "/workspace/speech", pinned: true, state: "idle" }, + { session_id: "review", summary: "代码审查", cwd: "/workspace", state: "idle" }, + { session_id: "deploy", summary: "部署检查", cwd: "/workspace", state: "idle", timed_tasks: [{ + task_id: "preview-timer", title: "检查部署状态", next_message_at: Date.now() / 1000 + 780, + interval_seconds: 900, sent_count: 1, total_count: 3, valid_until: Date.now() / 1000 + 3600, + }] }, + ].map((session, index) => ({ ...session, engine, space, + codex_profile_id: index === 1 ? "nyx" : "default", claude_profile_id: index === 1 ? "nyx" : "default" })) as SessionInfo[]; + return
+ setSidebarOpen(false)} onNew={() => setActive("new")} + onNewInDir={noop} onRename={noop} onArchive={noop} onPin={noop} onDelete={noop} + onForkWorktree={noop} onMigrate={noop} /> +
+
+ +
Code
主题预览 · 示例会话
+ idle + + false} onOpenUsageActivity={noop} onLogout={noop} /> +
+ + } + connState="connected" wrapperOnline sendMode={sendMode} setSendMode={setSendMode} + queue={[]} pendingSend={null} failedDeferred={[]} unconfirmedQueued={[]} unconfirmedReplaceable={[]} + queueCapacity={{}} replaceQueueCapacity={{}} + model={engine === "codex" ? "gpt-6-astra" : String(engine) === "dsh" ? "DeepSeek-V41-Flash" : "claude-fable-5-1"} effort="max" + perm={engine === "codex" ? "danger-full-access" : "bypassPermissions"} + permissionProfile={null} permissionProfiles={null} webSearch={null} collaborationMode="default" + engine={engine} editPrompt={null} onEditConsumed={noop} onSendQuery={() => false} + onSteerQuery={() => false} onInterrupt={noop} onEnqueue={() => false} onSetPending={() => false} + onRemoveQueued={noop} onInspectQueued={noop} onSetModel={noop} onSetEffort={noop} onSetPerm={noop} + onSetPermissionProfile={noop} onGetPermissionProfiles={noop} onSetWebSearch={noop} + onSetCollaborationMode={noop} onClear={noop} onContext={noop} contextReport={null} /> +
+
; +} + +createRoot(document.getElementById("root")!).render(); diff --git a/web/tests/theme-preview.html b/web/tests/theme-preview.html new file mode 100644 index 00000000..0ad54b7f --- /dev/null +++ b/web/tests/theme-preview.html @@ -0,0 +1,13 @@ + + + + + + + cc-remote · 主题预览 + + +
+ + + diff --git a/web/tests/theme-preview.md b/web/tests/theme-preview.md new file mode 100644 index 00000000..6f3d2b95 --- /dev/null +++ b/web/tests/theme-preview.md @@ -0,0 +1,40 @@ +# Theme review preview + +The theme fixture uses production UI components with example conversations. It +does not connect to a model or an existing session. + +For local development, run `npm run dev` in `web` and open +`/tests/theme-preview.html?engine=codex`. This entry imports TSX and requires +Vite. Do not share the source HTML through Remote Viewer. + +For a phone or Remote Viewer review, use Node 24 and run: + +```bash +npm run build:theme-preview +``` + +Share the generated `web/dist-theme-preview/tests/theme-preview.html` file. +The export includes relative JavaScript, CSS and font assets and needs no +development server. If registering it manually, use `web/dist-theme-preview` +as the resource root and allow only `/tests/theme-preview.html` and `/assets/`. +Keep the Wrapper online and reopen the preview after rebuilding the files. + +The export retains the production theme controls and UI components, but omits +diagram and equation renderers unused by the fixed example conversation. It +bundles scripts together and enforces at most six files, each below 1.5 MiB: +Bridge reads and revalidates even lazy dependencies before the first paint, so +testing a large graph only on loopback would hide mobile relay startup delays. + +Validate the export through the real Bridge channel before sharing it: + +```bash +VIEWER_TEST_KIND=theme \ +VIEWER_TEST_SOURCE="$PWD/dist-theme-preview" \ +VIEWER_TEST_ENTRY=/tests/theme-preview.html \ +VIEWER_TEST_PATHS='["/tests/theme-preview.html","/assets/"]' \ +npx playwright test -c playwright.viewer.config.ts remote-viewer-theme.spec.ts +``` + +The Bridge sandbox blocks browser storage. Theme changes still work within the +open preview; persistent preferences are verified by `themes.spec.ts` in the +normal application context. diff --git a/web/tests/themes.spec.ts b/web/tests/themes.spec.ts new file mode 100644 index 00000000..90d75647 --- /dev/null +++ b/web/tests/themes.spec.ts @@ -0,0 +1,217 @@ +import { expect, test, type Page } from "@playwright/test"; + +const url = "/tests/theme-preview.html?engine=codex"; +const picker = (page: Page) => page.getByRole("dialog", { name: "主题", exact: true }); +async function openPicker(page: Page) { + await page.getByRole("button", { name: "更多设置", exact: true }).click(); + await page.getByRole("button", { name: /^主题/ }).click(); + await expect(picker(page)).toBeVisible(); +} + +test("theme picker applies all six palettes without losing the draft and restores classic colors", async ({ page }, testInfo) => { + await page.goto(url); + const root = page.locator("html"); + const input = page.locator(".inrow textarea"); + await input.fill("这条消息先保留"); + await openPicker(page); + await expect(picker(page).locator(".theme-card")).toHaveCount(6); + for (const [name, id, mode] of [ + ["奶油琥珀", "amber", "light"], ["森林苔绿", "moss", "light"], ["玫瑰雾", "rose", "light"], + ["深海青", "lagoon", "dark"], ["酒红夜色", "bordeaux", "dark"], ["复古终端绿", "heritage", "dark"], + ]) { + await picker(page).getByRole("button", { name, exact: true }).click(); + await expect(root).toHaveAttribute("data-palette", id); + await expect(root).toHaveAttribute("data-theme", mode); + await expect(input).toHaveValue("这条消息先保留"); + await expect(picker(page).getByRole("status")).toHaveText(`已选:${name}`); + expect(await root.evaluate(node => getComputedStyle(node).colorScheme)).toBe(mode); + const miniature = picker(page).getByRole("button", { name, exact: true }).locator(".theme-miniature"); + expect(await page.locator(".pane").evaluate(node => getComputedStyle(node).backgroundColor)) + .toBe(await miniature.evaluate(node => getComputedStyle(node).backgroundColor)); + } + await page.screenshot({ path: testInfo.outputPath("theme-picker.png"), animations: "disabled" }); + await picker(page).getByRole("button", { name: "经典浅色", exact: true }).click(); + await expect(root).toHaveAttribute("data-palette", "classic"); + expect(await root.evaluate(node => (node as HTMLElement).style.getPropertyValue("--bg"))).toBe(""); + await page.keyboard.press("Escape"); + await expect(picker(page)).toHaveCount(0); + await expect(page.getByRole("button", { name: "更多设置", exact: true })).toBeFocused(); + await expect(input).toHaveValue("这条消息先保留"); +}); + +test("theme picker remembers each engine and survives a reload", async ({ page }) => { + await page.goto(url); + await openPicker(page); + await picker(page).getByRole("button", { name: "森林苔绿", exact: true }).click(); + await picker(page).getByRole("button", { name: "完成", exact: true }).click(); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + await page.getByRole("menuitemradio", { name: "Claude" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "classic"); + await openPicker(page); + await picker(page).getByRole("button", { name: "酒红夜色", exact: true }).click(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + await page.getByRole("menuitemradio", { name: "Codex" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "moss"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "moss"); + const preferences = await page.evaluate(() => JSON.parse(localStorage.getItem("cc_remote_themes_v1")!)); + expect(preferences).toMatchObject({ codex: "moss", claude: "bordeaux" }); +}); + +test("theme picker follows the system only when selected and preserves old preferences", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.addInitScript(() => localStorage.setItem("cc_remote_theme", "dark")); + await page.goto(url); + const root = page.locator("html"); + await expect(root).toHaveAttribute("data-theme", "dark"); + await openPicker(page); + await expect(picker(page).getByRole("button", { name: "经典深色", exact: true })).toHaveAttribute("aria-pressed", "true"); + await picker(page).getByRole("button", { name: "跟随系统", exact: true }).click(); + await expect(root).toHaveAttribute("data-theme", "light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(root).toHaveAttribute("data-theme", "dark"); + await picker(page).getByRole("button", { name: "奶油琥珀", exact: true }).click(); + await page.emulateMedia({ colorScheme: "light" }); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(root).toHaveAttribute("data-theme", "light"); + expect(await page.evaluate(() => localStorage.getItem("cc_remote_theme"))).toBe("dark"); +}); + +test("theme picker stays centered and scrollable when the mobile viewport shrinks", async ({ page }, testInfo) => { + test.skip((page.viewportSize()?.width ?? 0) > 600, "Mobile viewport"); + await page.goto(url); + await openPicker(page); + const panel = picker(page); + const size = page.viewportSize()!; + const initial = (await panel.boundingBox())!; + expect(Math.abs(initial.x + initial.width / 2 - size.width / 2)).toBeLessThan(2); + await expect(panel.locator("select")).toHaveCount(0); + await page.setViewportSize({ width: size.width, height: 380 }); + await expect.poll(async () => { + const box = (await panel.boundingBox())!; + return box.y >= 0 && box.y + box.height <= 380; + }).toBe(true); + await panel.getByRole("button", { name: "复古终端绿", exact: true }).click(); + await expect(panel.getByRole("button", { name: "完成", exact: true })).toBeVisible(); + await page.setViewportSize(size); + await expect.poll(async () => (await panel.boundingBox())!.height).toBeCloseTo(initial.height, 0); + await page.screenshot({ path: testInfo.outputPath("mobile-theme-picker.png"), animations: "disabled" }); +}); + +test("theme picker receives another tab's selection without changing other engines", async ({ page }) => { + await page.goto(url); + await openPicker(page); + await picker(page).getByRole("button", { name: "森林苔绿", exact: true }).click(); + const other = await page.context().newPage(); + await other.goto("/tests/theme-preview.html?engine=claude"); + await openPicker(other); + await picker(other).getByRole("button", { name: "玫瑰雾", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "moss"); + await other.goto(url); + await expect(other.locator("html")).toHaveAttribute("data-palette", "moss"); + await openPicker(other); + await picker(other).getByRole("button", { name: "深海青", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "lagoon"); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem("cc_remote_themes_v1")!))) + .toMatchObject({ claude: "rose", codex: "lagoon" }); + await other.close(); +}); + +test("theme picker restores DSH colors independently of Codex", async ({ page }) => { + await page.goto("/tests/theme-preview.html?engine=dsh"); + await expect(page.locator("html")).toHaveAttribute("data-engine", /claude|dsh/); + test.skip(await page.locator("html").getAttribute("data-engine") !== "dsh", "DSH branch only"); + await openPicker(page); + await picker(page).getByRole("button", { name: "复古终端绿", exact: true }).click(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + await page.getByRole("menuitemradio", { name: "Codex" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "classic"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + await page.getByRole("menuitemradio", { name: "DSH" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "heritage"); +}); + + +const textSamples = ["body", ".prose p", ".prose h3", ".prose strong", ".prose pre code", + ".scard-title", ".inrow textarea", ".c-head .ttl", ".header-menu-item small"]; +async function sampleTypography(page: Page) { + return Promise.all(textSamples.map(selector => page.locator(selector).first().evaluate(node => { + const style = getComputedStyle(node); + return { weight: Number(style.fontWeight), size: style.fontSize, family: style.fontFamily }; + }))); +} + +test("bold text preserves hierarchy and drafts across engines, themes and reloads", async ({ page }, info) => { + await page.goto(url); + const input = page.locator(".inrow textarea"); + await input.fill("保留草稿,调整阅读效果"); + await page.getByRole("button", { name: "更多设置", exact: true }).click(); + const toggle = page.getByRole("switch", { name: "加粗字体", exact: true }); + await expect(toggle).not.toBeChecked(); + const normal = await sampleTypography(page); + await toggle.focus(); + await page.keyboard.press("Space"); + await expect(toggle).toBeChecked(); + await expect(page.locator("html")).toHaveAttribute("data-bold-text", "true"); + const bold = await sampleTypography(page); + for (let index = 0; index < normal.length; index++) { + expect(bold[index], textSamples[index]).toEqual({ ...normal[index], weight: normal[index].weight + 200 }); + } + await expect(input).toHaveValue("保留草稿,调整阅读效果"); + await page.screenshot({ path: info.outputPath("bold-text-menu.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await openPicker(page); + await picker(page).getByRole("button", { name: "森林苔绿", exact: true }).click(); + // Lazy sheets also inherit the same weight scale. + await expect(picker(page).locator(".theme-card-name").first()).toHaveCSS("font-weight", "800"); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + await page.getByRole("menuitemradio", { name: "Claude" }).click(); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + const dsh = page.getByRole("menuitemradio", { name: "DSH" }); + if (await dsh.count()) { + await dsh.click(); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await page.getByRole("button", { name: "切换新会话引擎" }).click(); + } + await page.getByRole("menuitemradio", { name: "Codex" }).click(); + await expect(input).toHaveValue("保留草稿,调整阅读效果"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-bold-text", "true"); + await expect(page.locator("html")).toHaveAttribute("data-palette", "moss"); + await page.getByRole("button", { name: "更多设置", exact: true }).click(); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(page.locator("html")).toHaveAttribute("data-bold-text", "false"); + expect(await sampleTypography(page)).toEqual(normal); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-bold-text", "false"); +}); + +test("bold text syncs between browser tabs and works without storage", async ({ page }) => { + await page.goto(url); + const other = await page.context().newPage(); + await other.goto("/tests/theme-preview.html?engine=claude"); + await page.getByRole("button", { name: "更多设置", exact: true }).click(); + await page.getByRole("switch", { name: "加粗字体", exact: true }).click(); + await expect(other.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await other.getByRole("button", { name: "更多设置", exact: true }).click(); + await expect(other.getByRole("switch", { name: "加粗字体", exact: true })).toBeChecked(); + await other.getByRole("switch", { name: "加粗字体", exact: true }).click(); + await expect(page.getByRole("switch", { name: "加粗字体", exact: true })).not.toBeChecked(); + await other.close(); + + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.addInitScript(() => Object.defineProperty(window, "localStorage", { + get() { throw new DOMException("Storage unavailable", "SecurityError"); }, + })); + await page.reload(); + await page.getByRole("button", { name: "更多设置", exact: true }).click(); + await page.getByRole("switch", { name: "加粗字体", exact: true }).click(); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + expect(errors).toEqual([]); +}); diff --git a/web/tests/themes.test.ts b/web/tests/themes.test.ts new file mode 100644 index 00000000..605e04fe --- /dev/null +++ b/web/tests/themes.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + LEGACY_THEME_KEY, THEME_STORAGE_KEY, THEME_PALETTES, + readThemePreferences, resolveThemeMode, saveThemeChoice, +} from "../src/themes.ts"; + +const values = new Map(); +const storage = { getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); } }; +assert.equal(readThemePreferences(storage).codex, "system"); +values.set(LEGACY_THEME_KEY, "dark"); +assert.deepEqual(readThemePreferences(storage), { claude: "dark", codex: "dark", dsh: "dark" }); +values.set(THEME_STORAGE_KEY, '{"codex":"moss","claude":"invalid","dsh":"lagoon"}'); +assert.deepEqual(readThemePreferences(storage), { claude: "dark", codex: "moss", dsh: "lagoon" }); +const olderTab = readThemePreferences(storage); +saveThemeChoice(storage, olderTab, "dsh", "rose"); +saveThemeChoice(storage, olderTab, "codex", "amber"); +assert.deepEqual(readThemePreferences(storage), { claude: "dark", codex: "amber", dsh: "rose" }, + "a stale tab must not overwrite another engine's latest preference"); +assert.equal(values.get(LEGACY_THEME_KEY), "dark"); +values.set(THEME_STORAGE_KEY, "{invalid"); +assert.equal(readThemePreferences(storage).codex, "dark"); +saveThemeChoice(storage, {}, "codex", "bordeaux"); +assert.equal(readThemePreferences(storage).codex, "bordeaux", "malformed storage is recoverable"); +const blocked = { getItem: () => { throw new Error("unavailable"); }, setItem: () => { throw new Error("unavailable"); } }; +assert.deepEqual(readThemePreferences(blocked), {}); +assert.deepEqual(saveThemeChoice(blocked, { claude: "rose" }, "codex", "moss"), { claude: "rose", codex: "moss" }); +assert.equal(resolveThemeMode("system", true), "dark"); +assert.equal(resolveThemeMode("system", false), "light"); +assert.equal(resolveThemeMode("moss", true), "light"); +assert.equal(resolveThemeMode("bordeaux", false), "dark"); + +function luminance(hex: string) { + const rgb = hex.slice(1).match(/../g)!.map(value => { + const channel = parseInt(value, 16) / 255; + return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4; + }); + return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722; +} +function contrast(a: string, b: string) { + const x = luminance(a), y = luminance(b); + return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); +} +const css = readFileSync("src/themes.css", "utf8"); +for (const palette of THEME_PALETTES) { + const body = css.split(`[data-preview-palette="${palette.id}"] {`)[1]?.split("}")[0]; + assert.ok(body, `${palette.id}: palette has a stylesheet`); + const colors = Object.fromEntries([...body.matchAll(/--([\w-]+):(#[\da-fA-F]+);/g)].map(match => [match[1], match[2]])); + for (const surface of ["bg", "surface", "sidebar", "raised", "accent-weak"] as const) { + for (const ink of ["text", "dim", "accent-ink"] as const) { + assert.ok(contrast(colors[ink], colors[surface]) >= 4.5, + `${palette.id}: ${ink} remains readable on ${surface}`); + } + } + assert.ok(contrast(colors["on-accent"], colors.accent) >= 4.5, + `${palette.id}: muted primary buttons retain readable labels`); +} +console.log("themes: legacy migration, per-engine persistence, cross-tab merge, system mode and text contrast passed"); diff --git a/web/vite.theme-preview.config.ts b/web/vite.theme-preview.config.ts new file mode 100644 index 00000000..fe6361fa --- /dev/null +++ b/web/vite.theme-preview.config.ts @@ -0,0 +1,51 @@ +import { resolve } from "node:path"; +import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +function reviewBundle(): Plugin { + return { + name: "theme-review-bundle", + enforce: "pre", + // Bridge eagerly walks the full lazy import graph. These two renderers + // are unused by the fixed example conversation; omit them only here. + resolveId(source, importer) { + if (source === "mermaid") return "\0theme-review-diagrams"; + if (source === "./markdown-math-plugins" && importer?.endsWith("/src/markdown-math.ts")) + return "\0theme-review-math"; + }, + load(id) { + if (id === "\0theme-review-diagrams") return `export default { + initialize() {}, + render() { throw new Error("主题预览不包含图表,请在正式会话中查看。"); } + };`; + if (id === "\0theme-review-math") + return "export const remarkPlugins = []; export const rehypePlugins = [];"; + }, + generateBundle(_, bundle) { + const files = Object.values(bundle); + if (files.length > 6) this.error("Theme review must stay within six static resources."); + for (const file of files) { + const bytes = Buffer.byteLength(file.type === "chunk" ? file.code : file.source); + if (bytes > 1536 * 1024) this.error(`Theme review resource exceeds 1.5 MiB: ${file.fileName}`); + } + }, + }; +} + +// Remote Viewer reads static files; it cannot execute the Vite/TSX dev entry. +// Keep this standalone review bundle separate from the deployed application. +export default defineConfig({ + plugins: [react(), reviewBundle()], + base: "./", + build: { + outDir: "dist-theme-preview", + emptyOutDir: true, + copyPublicDir: false, + modulePreload: false, + cssCodeSplit: false, + chunkSizeWarningLimit: 1536, + rolldownOptions: { + input: resolve(import.meta.dirname, "tests/theme-preview.html"), + output: { codeSplitting: false }, + }, + }, +}); From 231092109b028579759980e62c523829e778d1ba Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Wed, 16 Sep 2026 18:00:18 +0800 Subject: [PATCH 06/17] fix(ui): strengthen bold text across fallback fonts - Raise reading weights while preserving emphasis and font metrics. - Add a subtle text stroke without changing SVG icon geometry. - Cover cross-tab preferences, engine switches, and storage failures. --- web/src/typography.css | 15 +++++++++++---- web/tests/themes.spec.ts | 27 +++++++++++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/web/src/typography.css b/web/src/typography.css index c981e041..dfd561e6 100644 --- a/web/src/typography.css +++ b/web/src/typography.css @@ -1,6 +1,7 @@ /* Use these weights for app text, including font shorthands, so the reading preference also reaches lazy dialogs. The normal scale preserves existing typography exactly; bold text raises each weight without flattening it. + A fine text stroke also strengthens fallback fonts with limited weights. Embedded previews keep their own document and typography. */ :root{ --font-weight-400:400; @@ -11,17 +12,23 @@ --font-weight-650:650; --font-weight-700:700; --font-weight-750:750; + --reading-text-stroke:0px; } :root[data-bold-text="true"]{ - --font-weight-400:600; - --font-weight-500:700; - --font-weight-550:750; - --font-weight-560:760; + --font-weight-400:700; + --font-weight-500:750; + --font-weight-550:775; + --font-weight-560:780; --font-weight-600:800; --font-weight-650:850; --font-weight-700:900; --font-weight-750:950; + --reading-text-stroke:0.3px; } body,select{font-weight:var(--font-weight-400)} +body{-webkit-text-stroke:var(--reading-text-stroke) currentColor} +/* Icons and inline diagrams retain their own geometry. Iframes are separate + documents and never inherit the app's reading preference. */ +svg{-webkit-text-stroke:0} h1,h2,h3,h4,h5,h6,th{font-weight:var(--font-weight-700)} diff --git a/web/tests/themes.spec.ts b/web/tests/themes.spec.ts index 90d75647..b163b2f3 100644 --- a/web/tests/themes.spec.ts +++ b/web/tests/themes.spec.ts @@ -139,7 +139,8 @@ const textSamples = ["body", ".prose p", ".prose h3", ".prose strong", ".prose p async function sampleTypography(page: Page) { return Promise.all(textSamples.map(selector => page.locator(selector).first().evaluate(node => { const style = getComputedStyle(node); - return { weight: Number(style.fontWeight), size: style.fontSize, family: style.fontFamily }; + return { weight: Number(style.fontWeight), size: style.fontSize, family: style.fontFamily, + stroke: parseFloat(style.webkitTextStrokeWidth) }; }))); } @@ -157,8 +158,22 @@ test("bold text preserves hierarchy and drafts across engines, themes and reload await expect(page.locator("html")).toHaveAttribute("data-bold-text", "true"); const bold = await sampleTypography(page); for (let index = 0; index < normal.length; index++) { - expect(bold[index], textSamples[index]).toEqual({ ...normal[index], weight: normal[index].weight + 200 }); + const sample = bold[index]; + expect(sample.size, textSamples[index]).toBe(normal[index].size); + expect(sample.family, textSamples[index]).toBe(normal[index].family); + expect(sample.weight, textSamples[index]).toBeGreaterThan(normal[index].weight); + expect(sample.weight, textSamples[index]).toBeGreaterThanOrEqual(700); + expect(sample.stroke, textSamples[index]).toBeGreaterThan(0); + expect(sample.stroke, textSamples[index]).toBeLessThanOrEqual(0.35); + // Emphasis must remain distinguishable from ordinary body text. + for (let other = 0; other < normal.length; other++) { + if (normal[index].weight > normal[other].weight) { + expect(sample.weight).toBeGreaterThan(bold[other].weight); + } + } } + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "700"); + await expect(page.locator(".c-head svg").first()).toHaveCSS("-webkit-text-stroke-width", "0px"); await expect(input).toHaveValue("保留草稿,调整阅读效果"); await page.screenshot({ path: info.outputPath("bold-text-menu.png"), animations: "disabled" }); await page.keyboard.press("Escape"); @@ -169,12 +184,12 @@ test("bold text preserves hierarchy and drafts across engines, themes and reload await page.keyboard.press("Escape"); await page.getByRole("button", { name: "切换新会话引擎" }).click(); await page.getByRole("menuitemradio", { name: "Claude" }).click(); - await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "700"); await page.getByRole("button", { name: "切换新会话引擎" }).click(); const dsh = page.getByRole("menuitemradio", { name: "DSH" }); if (await dsh.count()) { await dsh.click(); - await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "700"); await page.getByRole("button", { name: "切换新会话引擎" }).click(); } await page.getByRole("menuitemradio", { name: "Codex" }).click(); @@ -197,7 +212,7 @@ test("bold text syncs between browser tabs and works without storage", async ({ await other.goto("/tests/theme-preview.html?engine=claude"); await page.getByRole("button", { name: "更多设置", exact: true }).click(); await page.getByRole("switch", { name: "加粗字体", exact: true }).click(); - await expect(other.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await expect(other.locator(".prose p").first()).toHaveCSS("font-weight", "700"); await other.getByRole("button", { name: "更多设置", exact: true }).click(); await expect(other.getByRole("switch", { name: "加粗字体", exact: true })).toBeChecked(); await other.getByRole("switch", { name: "加粗字体", exact: true }).click(); @@ -212,6 +227,6 @@ test("bold text syncs between browser tabs and works without storage", async ({ await page.reload(); await page.getByRole("button", { name: "更多设置", exact: true }).click(); await page.getByRole("switch", { name: "加粗字体", exact: true }).click(); - await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "600"); + await expect(page.locator(".prose p").first()).toHaveCSS("font-weight", "700"); expect(errors).toEqual([]); }); From 9ec92e8a7809e90613c53aa78eecfd397268b398 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Wed, 16 Sep 2026 18:02:41 +0800 Subject: [PATCH 07/17] feat(btw): support attachments and per-chat service tiers - Send image and file payloads through query, steer, and deferred delivery. - Scope drops and clipboard imports to the intended draft across tab switches. - Keep attachment-only sends distinct from interruption and respect input locks. - Add Codex service-tier controls scoped to the side chat. --- web/src/App.tsx | 37 +++---- web/src/components/BtwPanel.css | 44 ++++++++ web/src/components/BtwPanel.tsx | 160 ++++++++++++++++++++++++------ web/src/components/Composer.tsx | 36 +------ web/src/index.css | 41 +------- web/src/use-attachment-drop.ts | 38 +++++++ web/src/ws.ts | 4 +- web/tests/history-browser.spec.ts | 146 +++++++++++++++++++++++++++ 8 files changed, 381 insertions(+), 125 deletions(-) create mode 100644 web/src/components/BtwPanel.css create mode 100644 web/src/use-attachment-drop.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index d87c0e00..4701401d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5315,18 +5315,22 @@ export default function App() { if (!parentSid) return; dispatch({ type: "select_btw", parentSid, btwSid: sid }); }; - const sendBtw = (prompt: string): boolean => { + const sendBtw = ( + prompt: string, images?: QueryImg[], files?: QueryFile[], steer = false, + ): boolean => { const sid = activeBtwSid; const ws = wsRef.current; if (!sid || !ws || runtimeIsReadOnly(sid)) return false; + if (steer && activeBtw?.engine !== "codex") return false; const runtime = stateRef.current.runtimes[sid]; const awaitingAcceptance = !!( ws.pendingQueryFor(sid) || runtime?.acceptancePending ); - if (awaitingAcceptance || runtime?.queue.length || runtime?.pendingSend) { + if (steer && awaitingAcceptance) return false; + const query = { prompt, images, files }; + if (!steer && (awaitingAcceptance || runtime?.queue.length || runtime?.pendingSend)) { const delivery = awaitingAcceptance && activeBtwSendMode !== "queue" ? "replace" : "queue"; - const query = { prompt }; const currentState = stateRef.current; const unconfirmed = collectUnconfirmedQueries( currentState.runtimes, @@ -5341,25 +5345,17 @@ export default function App() { return sendDeferredQuery(sid, query, delivery); } const msg_id = uuid(); - if (!ws.sendQueryTo(sid, prompt, msg_id)) return false; + const accepted = steer + ? ws.sendSteerTo(sid, prompt, msg_id, images, files) + : ws.sendQueryTo(sid, prompt, msg_id, images, files); + if (!accepted) return false; dispatch({ - type: "query_sent", sid, prompt, msg_id, ts: Date.now(), - }); - return true; - }; - const steerBtw = (prompt: string): boolean => { - const sid = activeBtwSid; - const ws = wsRef.current; - if (!sid || !ws || activeBtw?.engine !== "codex" || runtimeIsReadOnly(sid)) return false; - const runtime = stateRef.current.runtimes[sid]; - if (ws.pendingQueryFor(sid) || runtime?.acceptancePending) return false; - const msg_id = uuid(); - if (!ws.sendSteerTo(sid, prompt, msg_id)) return false; - dispatch({ - type: "steer_sent", sid, prompt, msg_id, ts: Date.now(), + type: steer ? "steer_sent" : "query_sent", sid, ...query, msg_id, ts: Date.now(), }); return true; }; + const steerBtw = (prompt: string, images?: QueryImg[], files?: QueryFile[]): boolean => + sendBtw(prompt, images, files, true); const interruptBtw = (sid: string) => { if (runtimeIsReadOnly(sid)) return; wsRef.current?.sendInterruptTo(sid); @@ -6131,6 +6127,11 @@ export default function App() { onSetEffort={(effort) => { if (activeBtwSid) setBtwEffort(activeBtwSid, effort); }} + onSetServiceTier={(tier) => { + if (!activeBtwSid || activeBtw?.engine !== "codex" + || runtimeIsReadOnly(activeBtwSid)) return false; + return wsRef.current?.sendSetServiceTier(tier, activeBtwSid) ?? false; + }} onSetAutoCompact={(selection) => activeBtwSid ? setBtwAutoCompact(activeBtwSid, selection) : false} onOpenFile={previewBtwFile} onCollapse={collapseBtw} diff --git a/web/src/components/BtwPanel.css b/web/src/components/BtwPanel.css new file mode 100644 index 00000000..3e9e3999 --- /dev/null +++ b/web/src/components/BtwPanel.css @@ -0,0 +1,44 @@ +/* /btw ephemeral side-fork panel (mini chat over a forked session) */ +.btw-panel{ position:fixed; top:14px; right:14px; bottom:14px; width:var(--panel-w); z-index:36; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-panel); box-shadow:var(--shadow-lg); display:flex; flex-direction:column; overflow:hidden; animation:panel-in .24s var(--ease); } +.btw-head{ display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border); flex:none; padding-top:max(12px, env(safe-area-inset-top)); } +.btw-titles{ flex:1; display:flex; flex-direction:column; gap:1px; min-width:0; } +.btw-title{ font-weight:var(--font-weight-650); font-size:13px; color:var(--accent-text,var(--accent)); } +.btw-sub{ font-size:11px; color:var(--dim); } +.btw-new{ color:var(--accent-text,var(--accent)); } +.btw-chat-tabs{ flex:none; display:flex; gap:6px; padding:8px 10px; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--surface) 88%,var(--bg)); } +.btw-chat-tab-wrap{ flex:0 0 auto; max-width:190px; display:flex; align-items:center; border:1px solid var(--border); border-radius:9px; background:var(--bg); color:var(--dim); transition:border-color .16s var(--ease),background .16s var(--ease),color .16s var(--ease); } +.btw-chat-tab-wrap.active{ border-color:color-mix(in srgb,var(--accent) 58%,var(--border)); background:color-mix(in srgb,var(--accent) 10%,var(--surface)); color:var(--text); } +.btw-chat-tab{ min-width:0; max-width:154px; display:flex; align-items:center; gap:7px; padding:7px 6px 7px 9px; border:0; background:transparent; color:inherit; font:inherit; font-size:11px; cursor:pointer; } +.btw-chat-label{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.btw-chat-state{ width:6px; height:6px; flex:none; border-radius:50%; background:var(--border-strong); } +.btw-chat-state.running,.btw-chat-state.interrupting,.btw-chat-state.draining{ background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent); } +.btw-chat-question{ flex:none; border-radius:999px; padding:1px 5px; color:var(--accent-text,var(--accent)); background:color-mix(in srgb,var(--accent) 14%,transparent); font-size:9px; line-height:14px; } +.btw-chat-close{ width:26px; height:28px; flex:none; display:grid; place-items:center; border:0; border-left:1px solid transparent; background:transparent; color:var(--dim); cursor:pointer; } +.btw-chat-close:hover{ color:var(--danger); } +.btw-body{ flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; padding:4px 2px; } +.btw-empty{ flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:28px 20px; color:var(--dim); font-size:13px; line-height:1.7; text-align:center; } +.btw-composer{ position:relative; flex:none; padding:9px 12px 8px; border-top:1px solid var(--border); background:var(--bg); } +.btw-composer-notice{ position:absolute; left:50%; bottom:calc(100% + 8px); z-index:3; transform:translateX(-50%); padding:7px 11px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); color:var(--dim); font-size:11px; white-space:nowrap; } +.btw-queued{ display:flex; gap:6px; padding:0 0 8px; overflow-x:auto; scrollbar-width:none; } +.btw-queued::-webkit-scrollbar{ display:none; } +.btw-queued .qchip{ flex:none; max-width:min(280px,72vw); } +.btw-queued .qx{ display:inline-flex; align-items:center; justify-content:center; } +.btw-runbar{ display:flex; padding:0 0 8px; } +.btw-runbar .seg{ margin-left:auto; } +.btw-input{ display:flex; align-items:flex-end; gap:8px; } +.btw-input textarea{ flex:1; min-width:0; resize:none; border:1px solid var(--border-strong); border-radius:12px; background:var(--bg); color:var(--text); font:inherit; font-size:14px; padding:9px 12px; max-height:120px; line-height:1.4; } +.btw-input .cmdbtn{ flex:none; align-self:center; } +.btw-input textarea:focus{ outline:none; border-color:var(--accent); } +.btw-input textarea:disabled{ background:color-mix(in srgb,var(--dim) 10%,var(--bg)); color:var(--dim); border-color:var(--border); cursor:not-allowed; opacity:.65; } +.btw-readonly-notice{ color:var(--dim); font-size:12px; line-height:1.5; margin-bottom:8px; overflow-wrap:anywhere; } +.btw-send{ flex:none; width:38px; height:38px; border-radius:10px; border:0; background:var(--accent); color:var(--on-accent,#fff); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; } +.btw-send.interrupt{ background:var(--danger); } +.btw-send:disabled{ opacity:.4; cursor:default; } +.btw-controls{ display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px 10px; min-width:0; padding:7px 2px 0; color:var(--faint); font-size:10px; } +.btw-controls>span{ margin-right:auto; white-space:nowrap; } +.btw-controls .hint-ctl{ max-width:42%; overflow:hidden; text-overflow:ellipsis; } +.btw-controls .fast-chip{ flex:none; max-width:none; } +@media (max-width:980px){ .btw-panel{ top:auto; left:10px; right:10px; bottom:calc(10px + var(--keyboard-inset,0px)); width:auto; max-height:min(82vh,calc(var(--app-height,100dvh) - 20px)); animation:panel-in-m .24s var(--ease); } } + +.drop-overlay-btw{ position:absolute; padding:16px; } +.drop-overlay-btw .drop-card{ width:100%; padding:28px 14px; text-align:center; } diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx index 6bc39133..044585b5 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -4,6 +4,7 @@ import { useLayoutEffect, useRef, useState, + type ClipboardEvent, type SetStateAction, } from "react"; import { ChatView } from "./ChatView"; @@ -53,6 +54,16 @@ import { import { AutoCompactControl } from "./AutoCompactControl"; import { QuestionSheet } from "./QuestionSheet"; import { sessionControlLocksInput } from "../protocol"; +import type { QueryImg, QueryFile } from "../protocol"; +import { AttachmentPicker } from "./AttachmentPicker"; +import { PendingImageAttachments } from "./PendingImageAttachments"; +import { attachmentBytes, snapshotAttachmentFiles } from "../img"; +import { + readClipboardImport, resolveClipboardImport, insertClipboardText, + type ClipboardImport, +} from "../clipboard-import"; +import { useAttachmentDrop } from "../use-attachment-drop"; +import "./BtwPanel.css"; interface Props { sid?: string; @@ -81,8 +92,8 @@ interface Props { onNew: () => void; onSelect: (sid: string) => void; onCloseChat: (sid: string) => void; - onSend: (prompt: string) => boolean; - onSteer: (prompt: string) => boolean; + onSend: (prompt: string, images?: QueryImg[], files?: QueryFile[]) => boolean; + onSteer: (prompt: string, images?: QueryImg[], files?: QueryFile[]) => boolean; onReplyAsyncQuestion?: (prompt: string) => Promise | null; onInterrupt: () => void; onSetSendMode: (mode: SendMode) => void; @@ -92,6 +103,7 @@ interface Props { onInspectQueued: (query: PendingQuery) => void; onSetModel: (model: string) => void; onSetEffort: (effort: string) => void; + onSetServiceTier: (tier: string) => boolean; onSetAutoCompact: (selection: AutoCompactSelection) => boolean; onOpenFile?: (path: string, line?: number) => void; imageAssets?: Record; @@ -119,11 +131,14 @@ export function BtwPanel(p: Props) { useState<"models" | "efforts" | null>(null); const [autoCompactOpen, setAutoCompactOpen] = useState(false); const [notice, setNotice] = useState(null); + const [importing, setImporting] = useState(false); + const importingRef = useRef(false); const noticeTimerRef = useRef(null); const taRef = useRef(null); const imeSubmitRef = useRef(new ImeSubmitGuard()); const buttonSendTimerRef = useRef(null); const input = draft.input; + const { images, files, pastes } = draft; const chats = p.chats; const turns = p.rt?.turns ?? []; const runtimeState = p.rt?.state ?? "idle"; @@ -135,6 +150,7 @@ export function BtwPanel(p: Props) { const inputLockedRef = useRef(inputLocked); inputLockedRef.current = inputLocked; const awaitingFirstChat = !!p.opening && !p.sid; + const attachmentsLocked = inputLocked || awaitingFirstChat || !p.sid; const activeTurnCandidates = activeTurnCandidateIds( turns, displayActiveTurnOwnerId( @@ -156,6 +172,8 @@ export function BtwPanel(p: Props) { const runtimeBusy = isComposerBusy(submitState); const busy = awaitingFirstChat || runtimeBusy; const hasText = input.trim().length > 0 || draft.pastes.length > 0; + const hasAttachments = images.length > 0 || files.length > 0; + const hasContent = hasText || hasAttachments; const updateDraft = useCallback(( update: (current: ComposerDraft) => ComposerDraft, @@ -210,7 +228,7 @@ export function BtwPanel(p: Props) { } }, []); - const flash = (message: string) => { + const flash = (message: string, duration = 2200) => { setNotice(message); if (noticeTimerRef.current !== null) { window.clearTimeout(noticeTimerRef.current); @@ -218,7 +236,56 @@ export function BtwPanel(p: Props) { noticeTimerRef.current = window.setTimeout(() => { noticeTimerRef.current = null; setNotice(null); - }, 2200); + }, duration); + }; + const onPickFiles = async ( + selected: FileList | File[] | null, clipboard?: ClipboardImport, + ) => { + if (attachmentsLocked) return; + if (importingRef.current) { flash("附件正在导入,请稍候"); return; } + const targetKey = draftKeyRef.current; + importingRef.current = true; + setImporting(true); + try { + // Capture the browser's FileList before the picker/drop event returns. + const [{ pickFiles }, imported] = await Promise.all([ + import("../attachment-import"), + clipboard ? resolveClipboardImport(clipboard) + : Promise.resolve(snapshotAttachmentFiles(selected, images.length + files.length)), + ]); + const batch = await pickFiles( + imported.files, images.length + files.length, attachmentBytes(images, files)); + const append = (current: ComposerDraft): ComposerDraft => ({ + ...current, + images: [...current.images, ...batch.images], + files: [...current.files, ...batch.files], + }); + if (draftKeyRef.current === targetKey) updateDraft(append); + else p.draftStore.set(targetKey, append(p.draftStore.get(targetKey))); + const errors = [...imported.errors, ...batch.errors]; + if (errors.length && draftKeyRef.current === targetKey) flash(errors.join(";"), 10_000); + } catch { + if (draftKeyRef.current === targetKey) { + flash("附件导入失败,请重新添加;已输入的文字会保留。", 10_000); + } + } finally { + importingRef.current = false; + setImporting(false); + } + }; + const dragOver = useAttachmentDrop("btw", attachmentsLocked || importing, onPickFiles); + const onPaste = (event: ClipboardEvent) => { + if (attachmentsLocked) return; + const clipboard = readClipboardImport(event.clipboardData, images.length + files.length); + const { text } = clipboard; + const attachments = clipboard.files.length || clipboard.images.length || clipboard.errors.length; + if (!attachments && text.length <= LONG_PASTE_THRESHOLD) return; + event.preventDefault(); + if (text.length > LONG_PASTE_THRESHOLD) { + const paste = makeComposerPaste(text, uuid()); + updateDraft((current) => ({ ...current, pastes: [...current.pastes, paste] })); + } else if (text) insertClipboardText(event.currentTarget, text, setInput); + if (attachments) void onPickFiles(null, clipboard); }; const resetTaHeight = () => { if (taRef.current) taRef.current.style.height = "auto"; @@ -233,7 +300,15 @@ export function BtwPanel(p: Props) { const submit = (value = taRef.current?.value ?? input) => { if (awaitingFirstChat || !p.sid || inputLockedRef.current) return; + if (importingRef.current) { flash("请等待附件导入完成"); return; } const command = parseSlash(value.trim()); + if (command?.slash === "fast" && p.engine === "codex") { + if (p.onSetServiceTier("toggle")) { + setInput(""); + resetTaHeight(); + } else flash("服务档位设置暂未发送,请稍后重试。"); + return; + } if (command?.slash === "open") { flash("请在主会话打开文件目录。"); setInput(""); @@ -276,15 +351,18 @@ export function BtwPanel(p: Props) { return; } const prompt = composed.prompt; - const query: PendingQuery = { prompt }; + const query: PendingQuery = { + prompt, images: images.length ? images : undefined, + files: files.length ? files : undefined, + }; if (runtimeBusy) { const action = classifyBusySubmit( submitState, p.sendMode, p.engine === "codex" ? "codex" : "claude", - prompt.length > 0); + prompt.length > 0 || hasAttachments); if (action === "noop") return; if (action === "steer") { - if (p.onSteer(prompt)) { + if (p.onSteer(prompt, query.images, query.files)) { clearDraft(); resetTaHeight(); } @@ -308,21 +386,21 @@ export function BtwPanel(p: Props) { resetTaHeight(); return; } - if (!prompt) return; - if (p.onSend(prompt)) { + if (!prompt && !hasAttachments) return; + if (p.onSend(prompt, query.images, query.files)) { clearDraft(); resetTaHeight(); } }; const requestButtonAction = () => { - if (inputLockedRef.current || buttonSendTimerRef.current !== null) return; + if (inputLockedRef.current || importingRef.current || buttonSendTimerRef.current !== null) return; buttonSendTimerRef.current = window.setTimeout(() => { buttonSendTimerRef.current = null; - if (inputLockedRef.current) return; + if (inputLockedRef.current || importingRef.current) return; const value = taRef.current?.value ?? input; // Stopping is an explicit button action. Empty Enter goes through submit // and remains a no-op. - if (runtimeBusy && !value.trim() && draft.pastes.length === 0) { + if (runtimeBusy && !value.trim() && pastes.length === 0 && !hasAttachments) { if (runtimeState === "running") p.onInterrupt(); return; } @@ -336,7 +414,7 @@ export function BtwPanel(p: Props) { ?? { id: p.rt.model, name: p.rt.model, ds: "", ic: "cpu" }) : null; const effortName = effortNameForDisplay(p.rt?.effort); - const stopping = runtimeBusy && !hasText; + const stopping = runtimeBusy && !hasContent; const interruptSettling = isInterruptSettling(submitState); const primaryIsInterrupt = p.engine !== "codex"; const sendIcon = !runtimeBusy ? "send" @@ -344,14 +422,14 @@ export function BtwPanel(p: Props) { ? (primaryIsInterrupt ? "bolt" : "send") : "queue"; const sendClass = "btw-send" + ((stopping || (runtimeBusy && p.sendMode === "steer" - && primaryIsInterrupt && hasText)) + && primaryIsInterrupt && hasContent)) ? " interrupt" : ""); - const sendDisabled = inputLocked || awaitingFirstChat || !p.sid - || (!runtimeBusy && !hasText) - || isSettlingStopDisabled(submitState, hasText); + const sendDisabled = attachmentsLocked || importing + || (!runtimeBusy && !hasContent) + || isSettlingStopDisabled(submitState, hasContent); return ( -
+
{p.hasArtifact @@ -456,10 +534,21 @@ export function BtwPanel(p: Props) { ))}
)} - {draft.pastes.length > 0 && ( + {(hasAttachments || pastes.length > 0) && (
- updateDraft(current => ({ + ...current, images: current.images.filter((_, candidate) => candidate !== index), + }))} /> + {files.map((file, index) => + {file.filename} + + )} + updateDraft((current) => ({ ...current, pastes, }))} /> @@ -481,6 +570,8 @@ export function BtwPanel(p: Props) {
)}
+