From 028a2498b4e331f023013536a74e9e08eaa178d3 Mon Sep 17 00:00:00 2001 From: Neil Evans Date: Mon, 19 Oct 2026 23:33:47 -0400 Subject: [PATCH] feat(chat): regenerate every session's title from the sidebar menu The sidebar header menu gains a Regenerate all titles panel. A dry-run preview counts the sessions it would retitle and the ones it leaves alone, and the run titles up to two sessions at once, counts down as each lands and names every session it could not retitle, each as a link that opens it. A model failure in the bulk path leaves the existing title in place instead of falling back to the opening message. Closes #21976 --- docs/feature-map/README.md | 1 + .../modules/learn-cron-dashboard.md | 4 +- src/kiro_crew/dashboard/chat.py | 4 + src/kiro_crew/dashboard/chat_title.py | 40 +- src/kiro_crew/dashboard/chat_title_bulk.py | 310 ++++++++ src/kiro_crew/dashboard/routes/chat.py | 2 + .../dashboard/state_parts/dashboard_state.py | 1 + src/kiro_crew/security_posture.py | 1 + test/test_chat_title.py | 19 + test/test_chat_title_bulk.py | 721 ++++++++++++++++++ test/test_json_object_body_guard.py | 1 + test/test_slot_running_readers.py | 1 + website/src/api/client/chatSlotSettings.ts | 16 + website/src/i18n/locales/bn.json | 21 +- website/src/i18n/locales/de.json | 21 +- website/src/i18n/locales/en-XA.json | 19 + website/src/i18n/locales/en.json | 21 +- website/src/i18n/locales/es.json | 22 +- website/src/i18n/locales/fr.json | 22 +- website/src/i18n/locales/hi.json | 21 +- website/src/i18n/locales/it.json | 22 +- website/src/i18n/locales/ja.json | 20 +- website/src/i18n/locales/ko.json | 20 +- website/src/i18n/locales/pt.json | 22 +- website/src/i18n/locales/ru.json | 23 +- website/src/i18n/locales/zh-CN.json | 20 +- website/src/i18n/pluralKeys.json | 3 +- website/src/pages/ChatSidebar.tsx | 5 +- .../src/pages/chat-sidebar/SidebarHeader.tsx | 7 +- website/src/pages/chat-sidebar/bulkTitles.tsx | 159 ++++ website/src/pages/chat-sidebar/scope.ts | 4 + .../switchSlotCallsiteClassification.test.ts | 1 + .../test/ApiClient.refactor.surface.test.ts | 2 +- .../src/test/ChatSidebar.bulkTitles.test.tsx | 385 ++++++++++ .../test/ChatSidebar.ownerComposition.test.ts | 2 +- 35 files changed, 1926 insertions(+), 37 deletions(-) create mode 100644 src/kiro_crew/dashboard/chat_title_bulk.py create mode 100644 test/test_chat_title_bulk.py create mode 100644 website/src/pages/chat-sidebar/bulkTitles.tsx create mode 100644 website/src/test/ChatSidebar.bulkTitles.test.tsx diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index b5c3de15a57..ab6facd15eb 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -132,6 +132,7 @@ this area is reached from inside it unless stated otherwise. | Session summary | Right-panel rolling summary of the conversation. With **Pin Summary in Side Panel** on (and session summaries on), Summary is the first pinned tab, cannot be closed, and is the tab the panel opens on; a panel opened for a file or another item shows that item | Chat right panel → **Summary** tab (**+** menu); Settings → Chat → Side panel → **Pin Summary in Side Panel** | `pages/chat/SessionSummaryTab.tsx`, `pages/chat/SidePanelAddMenu.tsx` (the **+** menu's Summary row, hidden while summaries are off or Summary is pinned), `pages/chat/usePinnedViews.ts` (the pin and the open-on-Summary rule), `hooks/usePanelTabs.ts` (`takePlainPanelShow`, the per-chat count of tab requests), `pages/chat/page/paneLayout.tsx` (ends a show when the panel closes or its chat changes), `pages/chat/SidePanel.tsx`, `pages/settings/ChatPanel.tsx` (the toggle) | `chat_handlers.py`, `file_api/dashboard_config.py` (the setting) | `GET,POST /api/chat/slots/{slot}/summary`, `GET,PUT /api/dashboard/config` (`side_panel_pin_summary`) | | Keep side panel open | Opens the chat's right side panel in every chat: a new chat, a chat you switch to, and the chat a reload lands on. Closing it hides it until you switch chats. Each chat's own saved open or closed choice is kept, and turning the setting off brings it back. Off on a phone and in an embedded companion chat; a popped-out chat window keeps it | Settings → Chat → Side panel → **Keep Side Panel Open** | `pages/settings/ChatPanel.tsx` (the toggle), `pages/chat/page/paneLayout.tsx` (applies it), `store/chat/activity.ts` (`setSidePanelAlwaysOpen`, `loadSlotActivity`) | `file_api/dashboard_config.py` | `GET,PUT /api/dashboard/config` (`side_panel_always_open`) | | Session cleanup | Bulk-archive inactive sessions to Older Sessions in one step: pick a window (no activity in the last 1, 3 or 7 days) and a dry-run preview lists the sessions that would move, with a count and an expandable list. The active session and pinned sessions are kept (the active one is reported as skipped when it is itself stale), and a slot that owns an armed auto-nudge loop is exempt because archiving it would mark it closed and terminally remove the loop; when the auto-nudge registry cannot be read the pass archives nothing and fails closed | Sidebar header ⋮ menu → **Clean up sessions** | `pages/chat-sidebar/sessionCleanup.tsx` (the cleanup panel and its day-window preview) | `chat_api/slot_lifecycle.py` (`api_chat_slots_cleanup`) | `POST /api/chat/slots/cleanup` | +| Regenerate all titles | Give every open session a fresh title from its recent conversation in one step. A dry-run preview counts the sessions it would retitle and the ones it leaves alone: running sessions, sessions with no conversation, sessions whose title limit of one bulk or agent call per 5 minutes is spent and sessions you renamed by hand, which a checkbox can include. Up to two titles generate at once, each lands in the sidebar as it finishes, and the panel counts the run down and reports how many were retitled, left as they were or failed, naming each failed session as a link that opens it | Sidebar header ⋮ menu → **Regenerate all titles…** | `pages/chat-sidebar/bulkTitles.tsx` | `chat_title_bulk.py` (`api_chat_slots_generate_titles`) | `POST /api/chat/generate-titles` (`dry_run`, `include_renamed`), `GET /api/chat/generate-titles` (progress) | | Broadcast message | Send one message to many open sessions at once. Quick picks (in a loop, in progress, needs me, all) show live counts, and the panel opens on the first one that reaches anyone; mate chats are only, included or excluded (excluded by default); folded custom criteria match any chosen state and narrow by folder with its sub-folders, tag, pinned or activity in the last 24 hours. Each matched session can be unticked. A session's own running turn gets the message as a steer, taken in after its current tool call (or after the user answers an approval or question it waits on); any other session gets a plain send, so the hold behind live sub-agents and queued messages applies, and an idle one starts a new turn. Sessions that cannot take a message (a peer crew's, a read-only relay archive) are never matched. Only an explicitly refused session can be retried; one whose receipt is unknown is never resent, and its row opens that session | Sidebar header ⋮ menu → **Broadcast message…** | `pages/chat-sidebar/BroadcastPanel.tsx` (the panel), `lib/broadcastTargets.ts` (who a broadcast reaches) | `chat_api/chat_send.py` (`api_chat`, steer or plain send) | `POST /api/chat` | | Crew log | Right-panel read of the session's append-only record, as the six folds the backend serves: lifecycle and turn status, credits / tokens / compactions, a timeline window, per-tool totals with the calls still open, approvals with what is waiting on the user, and the subagents this session dispatched with each one's outcome, duration and credits. Each fold is a section whose collapsed header carries its own figures; the footer states the record seq the values were folded through. A slot-key read resolves to the session the slot's turns run on, so a channel-linked slot folds the channel session's record rather than an empty one. Re-reads when the session's own turn stops and when its spawned work goes idle, plus a manual control; a read waits briefly for the writer to drain and says so when it could not, and an id with no addressable unit is reported as that rather than as nothing recorded. Diagnostics, listed with Logs and Context | Chat right panel → **+** menu → **Crew log**; also under `/members`, where the Crew Members side panel is the same `SidePanel` and offers the row once the member's thread confirms the slot | `pages/chat/CrewLogPanel.tsx`, `pages/chat/ActivityViewer.tsx`, `pages/chat/SidePanel.tsx` | `handlers/crew_log.py` | `GET /api/sessions/{id}/crew-log/projections` (every advertised panel fold from one read), `GET /api/sessions/{id}/crew-log/projection/{status,usage,timeline,tools,approvals,subagents}` | | Crew panel | An Ultra Mode chat's crew (code name Crew Mode) in its right side panel, as a **Crew** tab pinned first, opened on entry, with two views. **Status** (the default) shows the Overview (the ledger goal in one sentence and one "Done when" sentence), **Needs your input** (the crew's tool approvals and the main chat's question, as compact cards answered in place), **Milestones** (one row per work-ledger item: done, waiting on you, running or next, with a moving or stalled line) and a one-line **Recap** (the session summary's line when it is about goal progress, else a milestone sentence). **List** shows the crew as a tree, sub-teams indented under their lead and finished members folded away; a click on a member opens it in the main pane. The root's sidebar row carries a small ghost and "N sessions · M running". Offered only for a chat carrying a crew root while the Ultra Mode Feature Preview is on; every other chat's panel is unchanged | Chat right panel of an Ultra Mode chat → **Crew** tab (first, no ✕); Status \| List switch at the top | `pages/chat/command-center/UltraCrewPanel.tsx`, `pages/chat/command-center/CrewWorkerList.tsx`, `pages/chat/SidePanel.tsx`, `pages/chat/usePinnedViews.ts`, `pages/chat/command-center/AttentionCard.tsx`, `pages/chat-sidebar/SessionRow.tsx` | `dashboard/state_parts/slots_push.py` (the `crew_root` / `crew_mode_root` stamps; `crew_mode.py`), the crew list handler in `dashboard/handlers/crew_workers.py` (see **Crew panel routes**), the work-ledger board (`dashboard/handlers/work_ledger_board.py`) and the session summary (`GET /api/chat/slots/{slot}/summary`); a 404 from an older gateway is treated as no data (`crewRouteNotServed`) | `GET /api/chat/slots?crew=all`, `GET /api/chat/slots/{slot}/crew-workers`, `GET /api/crew-board?conductor={slot}`, `GET /api/chat/slots/{slot}/summary` | diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index 00e8fe923e5..7fc2ca1ea48 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -4416,7 +4416,7 @@ member-store identities produce a named error and never inherit Global V1. See [memory](memory-skills-hooks.md#memory-across-surfaces-and-channels) for canonical identity and selected knowledge copying. -**Chat**: POST `/api/chat` (SSE stream, or JSON with `?ws=1` — chunks via WebSocket; the SSE stream ends at the queue cycle's `done` row, except that an app sending on a user session through `permissions.sessionApproval` streams its own turn only, skipping `queued` placeholder rows, and stops at the `turn_end` wire frame (`chat_utils.TURN_END_WIRE_CLS`) pushed at the turn's end before the queue drain or `_finish_queue_cycle` writes anything (a held note, a drop notice, a successor turn's first row) — not before a recovery the ending turn queued for itself — which every other reader skips — see [app-kit-platform](app-kit-platform.md)), `/api/chat/slots` (CRUD, POST accepts optional `agent` field to set agent at creation, plus optional `agent_kind` (`member` | `template`) naming the catalog namespace the agent was picked from — a stated kind is resolved in that namespace only — before the slot is minted, so `409 agent_choice_unavailable` (it does not resolve there) registers no slot — `400 invalid_agent_kind` for any other value; a template kind pins no member memory store; list responses carry the committed `agent_kind`, `""` for a legacy name-first pick or a restored older slot; list responses include `source_links` extracted from slot messages with cached provider/number/state/CI metadata), resume from history, POST `/api/chat/slots/{slot}/generate-title`, POST `/api/chat/slots/{slot}/agent` (switch agent for slot; body `{agent, agent_kind?, announce?}` with the same `agent_kind` contract as create, and the response echoes the committed `agent_kind`; `announce: true`, sent by the composer's typed `/agent `, appends the same `🔄 Switched to agent: ` assistant row the in-turn command appends, only on a committed switch, and a non-boolean `announce` is `400 invalid_announce`; the picker sends no flag and instead gets the `session_switch` boundary row (`agent_switch._append_session_switch_notice`: one assistant row, `meta.kind: "session_switch"`, text from `system_notices.session_switch_notice`, a system notice every last-real-message scan skips), appended inside the slot and session locks only when the committed switch changed the agent (`agent_changed`, so a re-pick appends none) and only in a chat that has started (`_has_conversation`), and never alongside the `announce` line or the in-turn `/agent` command's own line (`SlotAgentSwitchCaller.notice`); 409 `member_memory_requires_new_conversation` when a member pick would move the memory binding of a conversation that already has history, 409 `session_rebound` ("slot changed during agent resolution") when the slot moved while the pick resolved; a pick applied on a remote instance whose local restart record did not persist still answers 2xx with `local_persistence: "pending"` and a `warning`), POST `/api/chat/slots/{slot}/model`, POST `/api/chat/slots/model` (bulk), POST `/api/chat/slots/{slot}/reasoning-effort` and POST `/api/chat/slots/{slot}/workspace` (each appends the same `session_switch` row, with the same started-chat rule, at its session-reset commit point inside the locks: model on the reset path only, not for a same-value pick, a live in-place switch or a refusal; bulk once per switched slot, none for an unchanged or skipped slot; effort on the reset path only when the level changed, not for the live-push, deferral or not-effort-capable paths; workspace not for the same workspace or a refusal), POST `/api/chat/slots/{slot}/fork` (fork session — copies visible messages into new slot, body: `{at_message_index?, prompt?}`, returns `{ok, key, title, messages, prompt, folder_id, pinned, direction, memory_mode}`, new slot has `forked_from` metadata and, for a pinned parent, the parent's sidebar pin (`chat_fork._inherit_pin`, applied through `fork_slot`'s `stamp` so it rides the birth save; see [session-control](session-control.md)), POST `/api/chat/slots/{slot}/edit-resend` (edit a user message and re-run; a real conversation boundary like `rewind` — discards the native ACP conversation, flushes the cleared resume sid, and persists the truncated window before the live slot adopts the edit. Body: `{index?, ts?, content}`. Refusals: 400 `invalid_content` (a present non-string `content`, which would otherwise reach `.strip()` as a 500) / 400 `content_too_long` (over 32768 chars, the same cap `rewind` and `fork` apply, checked before the destructive boundary), 409 `slot_subagents_running` (sub-agent children are attached to the session the discard would tear down — the same guard `reset-conversation` applies), 503 `edit_resend_prepare_failed` (discard or sid flush failed), 409 `edit_resend_session_busy` (a channel turn holds the session), 503 `edit_resend_save_failed`, 503 `edit_resend_slot_rebound` (the slot moved to another transcript mid-persistence). App tokens are gated by `slot_ownership.deny_app_slot_session_access`, so an app may edit-resend only a slot it owns whose session and transcript are its own — see [session](session.md) → "Edit rewind context boundary"), POST `/api/chat/slots/{slot}/rewind` (edit any past user message and re-run; fork-and-swap — truncates `slot.messages`, removes the slot's ACP session via `SessionManager.remove`, deletes orphaned kiro-cli session JSONL at `~/.kiro/sessions/cli/.json[l]`, then runs the edited prompt against a fresh ACP session under the same slot key/title/folder. Mirrors kiro-cli `/rewind`. Body: `{at_message_index?, ts?, content}`), PATCH `/api/chat/slots/{slot}/mode` (switch session mode — `_VALID_MODES` holds only `""`, and a request naming the retired `"orchestrator"` mode is coerced to `""`; 404 missing slot, 400 invalid mode, 409 while the session is running) +**Chat**: POST `/api/chat` (SSE stream, or JSON with `?ws=1` — chunks via WebSocket; the SSE stream ends at the queue cycle's `done` row, except that an app sending on a user session through `permissions.sessionApproval` streams its own turn only, skipping `queued` placeholder rows, and stops at the `turn_end` wire frame (`chat_utils.TURN_END_WIRE_CLS`) pushed at the turn's end before the queue drain or `_finish_queue_cycle` writes anything (a held note, a drop notice, a successor turn's first row) — not before a recovery the ending turn queued for itself — which every other reader skips — see [app-kit-platform](app-kit-platform.md)), `/api/chat/slots` (CRUD, POST accepts optional `agent` field to set agent at creation, plus optional `agent_kind` (`member` | `template`) naming the catalog namespace the agent was picked from — a stated kind is resolved in that namespace only — before the slot is minted, so `409 agent_choice_unavailable` (it does not resolve there) registers no slot — `400 invalid_agent_kind` for any other value; a template kind pins no member memory store; list responses carry the committed `agent_kind`, `""` for a legacy name-first pick or a restored older slot; list responses include `source_links` extracted from slot messages with cached provider/number/state/CI metadata), resume from history, POST `/api/chat/slots/{slot}/generate-title`, POST `/api/chat/generate-titles` (bulk; dashboard users only, so an app token gets 403; body `{include_renamed?, dry_run?}`, both booleans or 400; 409 `bulk_titles_in_progress` while a run is unfinished), GET `/api/chat/generate-titles` (the latest bulk run's progress, or `{run: null}`), POST `/api/chat/slots/{slot}/agent` (switch agent for slot; body `{agent, agent_kind?, announce?}` with the same `agent_kind` contract as create, and the response echoes the committed `agent_kind`; `announce: true`, sent by the composer's typed `/agent `, appends the same `🔄 Switched to agent: ` assistant row the in-turn command appends, only on a committed switch, and a non-boolean `announce` is `400 invalid_announce`; the picker sends no flag and instead gets the `session_switch` boundary row (`agent_switch._append_session_switch_notice`: one assistant row, `meta.kind: "session_switch"`, text from `system_notices.session_switch_notice`, a system notice every last-real-message scan skips), appended inside the slot and session locks only when the committed switch changed the agent (`agent_changed`, so a re-pick appends none) and only in a chat that has started (`_has_conversation`), and never alongside the `announce` line or the in-turn `/agent` command's own line (`SlotAgentSwitchCaller.notice`); 409 `member_memory_requires_new_conversation` when a member pick would move the memory binding of a conversation that already has history, 409 `session_rebound` ("slot changed during agent resolution") when the slot moved while the pick resolved; a pick applied on a remote instance whose local restart record did not persist still answers 2xx with `local_persistence: "pending"` and a `warning`), POST `/api/chat/slots/{slot}/model`, POST `/api/chat/slots/model` (bulk), POST `/api/chat/slots/{slot}/reasoning-effort` and POST `/api/chat/slots/{slot}/workspace` (each appends the same `session_switch` row, with the same started-chat rule, at its session-reset commit point inside the locks: model on the reset path only, not for a same-value pick, a live in-place switch or a refusal; bulk once per switched slot, none for an unchanged or skipped slot; effort on the reset path only when the level changed, not for the live-push, deferral or not-effort-capable paths; workspace not for the same workspace or a refusal), POST `/api/chat/slots/{slot}/fork` (fork session — copies visible messages into new slot, body: `{at_message_index?, prompt?}`, returns `{ok, key, title, messages, prompt, folder_id, pinned, direction, memory_mode}`, new slot has `forked_from` metadata and, for a pinned parent, the parent's sidebar pin (`chat_fork._inherit_pin`, applied through `fork_slot`'s `stamp` so it rides the birth save; see [session-control](session-control.md)), POST `/api/chat/slots/{slot}/edit-resend` (edit a user message and re-run; a real conversation boundary like `rewind` — discards the native ACP conversation, flushes the cleared resume sid, and persists the truncated window before the live slot adopts the edit. Body: `{index?, ts?, content}`. Refusals: 400 `invalid_content` (a present non-string `content`, which would otherwise reach `.strip()` as a 500) / 400 `content_too_long` (over 32768 chars, the same cap `rewind` and `fork` apply, checked before the destructive boundary), 409 `slot_subagents_running` (sub-agent children are attached to the session the discard would tear down — the same guard `reset-conversation` applies), 503 `edit_resend_prepare_failed` (discard or sid flush failed), 409 `edit_resend_session_busy` (a channel turn holds the session), 503 `edit_resend_save_failed`, 503 `edit_resend_slot_rebound` (the slot moved to another transcript mid-persistence). App tokens are gated by `slot_ownership.deny_app_slot_session_access`, so an app may edit-resend only a slot it owns whose session and transcript are its own — see [session](session.md) → "Edit rewind context boundary"), POST `/api/chat/slots/{slot}/rewind` (edit any past user message and re-run; fork-and-swap — truncates `slot.messages`, removes the slot's ACP session via `SessionManager.remove`, deletes orphaned kiro-cli session JSONL at `~/.kiro/sessions/cli/.json[l]`, then runs the edited prompt against a fresh ACP session under the same slot key/title/folder. Mirrors kiro-cli `/rewind`. Body: `{at_message_index?, ts?, content}`), PATCH `/api/chat/slots/{slot}/mode` (switch session mode — `_VALID_MODES` holds only `""`, and a request naming the retired `"orchestrator"` mode is coerced to `""`; 404 missing slot, 400 invalid mode, 409 while the session is running) **Nav link labels**: POST `/api/chat/nav/resolve-links` labels a batch of chat links with one background one-liner on the cheapest model. The reply is positional: label slot *i* belongs to link *i*, and `""` means no label (a line the prose guard rejects keeps its slot as `""` rather than shifting later labels). Only a recognised list preamble on an unnumbered first line ("Here are the labels:") is dropped; nothing else is. **Source-link unlink**: DELETE `/api/chat/slots/{slot}/source-links/{identity}` removes one PR/issue/Jira chip from a session's sidebar card. The chips are DERIVED — `SlotProjection.source_links` re-scans the transcript on every revision bump — so nothing is deleted; the endpoint records the chip's identity into the slot's **dismissed set** (`_ChatSlot._dismissed_source_links`, `dismiss_source_link(key)`), which the derivation filters against. Nothing touches a remote provider: this hides a chip, it never closes a pull request or an issue. **Identity, not URL**: `{identity}` is the serialized `SourceRef.identity` that every `source_links` entry carries as `identity` — `source_ref_identity_key()` renders the identity tuple to canonical JSON (fixed member order, no incidental whitespace, `ensure_ascii`) and `is_valid_source_identity_key()` validates an untrusted key against exactly that grammar — so a dismissal suppresses the OBJECT, and a re-mention of the same PR through another URL spelling (trailing slash, sub-path pin) stays gone. aiohttp percent-decodes the path segment once and the handler never `unquote`s again, so two identities that differ only by encoding cannot collapse onto one key. **Permanent by design**: a dismissal has no undo and no re-link, and a later paste of the same object does not resurface the chip; the set only grows within a session and is bounded by `_MAX_DISMISSED_SOURCE_LINKS` = 512, the operative ceiling; an identity is also accepted only when it is currently derived from the transcript or already dismissed (a format-valid but never-mentioned identity is a 404 `source_link_not_found`, so a caller cannot inflate durable state with junk). Refusals: 404 `slot_not_found` (identical to the sibling GET, so the response cannot probe which slots exist); the session-aware app-isolation denial of `slot_ownership.deny_app_slot_session_access` (the write lands on the transcript the slot ROUTES to, so an app may unlink only on a slot whose transcript is its own — the same gate `/autocompact`, `/context` and `/note` apply), which the per-slot checkpoint already applies before the handler runs; 400 `invalid_source_identity`; 400 `expected_identity_required` when the required `expect` query param (the session identity `||` the chip was rendered under) is absent — it is mandatory so the identity gate always runs; 409 `session_gone` when the slot was deleted or rebound while the request waited, when the transcript's identity cannot be read, when the guarded write declined, or when `expect` does not match the slot's current identity; 409 `dismissed_source_links_full` when the durable set is already at the `_MAX_DISMISSED_SOURCE_LINKS` = 512 ceiling. A repeat of an already-durable dismissal is an idempotent 200 that broadcasts nothing and writes nothing. Success answers `{ok: true, dismissed: true, source_links_total: }`; the client prefers that authoritative `source_links_total` over a local decrement (falling back to `Math.max(0, …-1)` only when an older gateway sends none) and otherwise reconciles from the slots push the endpoint broadcasts. **Persistence**: the set is the slot-owned metadata key `dismissed_source_links` (sorted, deterministic; in `SLOT_OWNED_META_KEYS`, hence in `ROWS_ONLY_DEFERRED_META_KEYS` so a rows-only handover carries the line's own value back verbatim — the key classes are [session](session.md)'s). The endpoint writes it FIELD-SCOPED through `update_metadata_if` under a per-transcript `_source_link_txn_lock` (alias slots of one transcript serialize together), guarded on the transcript's pinned `created_at` so a delete-and-recreate at the same path is declined rather than written into the replacement session, and the written value is `{identity}` (the one key this request is authorized to dismiss) united with the on-disk line, re-united with the line read under the lock by `_merge_guard`, so a departed alias's committed tombstone is never dropped. The live aliases' in-memory sets are deliberately left out: one can still carry a concurrent unlink's tentative dismissal for another identity, and folding it in would persist a tombstone for a link this transcript never dismissed. Both full-save branches of `_save_slot_to_history` likewise UNION the in-memory set with the on-disk line: a save can add a tombstone but can never shrink the durable set, whatever a slot's in-memory set happens to hold. Two flags make a save carry the on-disk line forward VERBATIM instead: `_dismissed_hydrated is False` (the slot was bound while the transcript's set could not be read — the binding is kept for routing, only the dismissed write is deferred) and `_dismissed_txn_depth > 0` (an unlink holds a tentative in-memory dismissal whose guarded write may still fail and roll back; a periodic flush must not persist it first). On a failed persist the handler rolls the tentative key out of every slot it added it to; a first write that already committed is NOT compensated (there is no cross-process lock spanning the first write and an undo, so undoing it could erase another gateway's acknowledged unlink). Instead it re-reads the transcript identity off-loop and, when that still matches the pin, ACCEPTS the committed grow-only tombstone as durable (disk holds the dismissal, so a 409 would desync); a changed or unreadable identity falls to the 409 rollback, leaving the replacement transcript untouched. **Hydration**: every path that binds a live slot to a transcript and applies its metadata or rows restores the mirror through `_restore_dismissed_source_links` — `_rehydrate_slot_from_history`, `_apply_recent_session`, `surface_channel_session`, `api_chat_slot_resume`, `_bind_cron_slot` (from the off-loop `prefetch_cron_dismissed` read; an unreadable read binds and marks the slot dismissed-unhydrated) and the deleted-job branch of `api_cron_to_chat`. Hydration is authoritative: a transcript with no key CLEARS a reused slot's set (a rebind never carries tombstones into another conversation), and each restored key is re-validated, a tampered entry being dropped rather than aborting the restore — a malformed key can only ever fail to match a real identity. `test_every_metadata_applying_bind_site_restores_dismissals` discovers the bind sites structurally and fails by name on a new one that skips the mirror; because the saves union, a path that forgets the mirror regresses to a stale chip in that slot, not to erasure. **Audit**: every attempt that reaches the handler is a permission-class SEL tool invocation named `source_link_unlink` — `allowed` on success (with `already_dismissed`) and `failed` with the `phase` (`lookup`, `ownership`, `expected_identity`, `validate`, `derive`, `reauth`, `lock_rebind`, `identity_pin`, `identity_pin_reauth`, `identity_pin_rebind`, `derive_pinned` (a 404 `source_link_not_found`), `cap`, `metadata_persist`) on every refusal; a cancelled request records `request_cancelled` under phase `request`, and a failed post-persist push records `broadcast_failed` under the publish phase that failed. An app caller on a slot it does not own, or that does not exist, never reaches it: the per-slot checkpoint refuses it first, with an `app_isolation` `api_access` row (operation `slot_route DELETE /api/chat/slots/{slot}/source-links/{identity}`) for a slot that exists and no row for a missing one ([App Kit platform §13](app-kit-platform.md)). **Frontend**: the affordance lives in the session card's existing shared actions menu (`SessionActionsMenu` ⋮), as a **“Hide a PR or Issue chip”** submenu (`SourceLinksSubmenu`, catalog key `source_link_actions`) listing the chips that carry an `identity` (a chip from an older gateway that sent none is not offered); picking one STAGES it (the entry switches to `unlink_source_link_confirm`, “Select again to permanently hide «label»”) and a second select on that staged entry issues the DELETE — a per-item two-step confirmation, since the removal is permanent — hiding the chip optimistically while the mutation is pending (the strip filters pending identities out) and reconciling from the slots push; the submenu is disabled while the gateway is offline, and a failure renders the shared `ErrorNotice` with the localized `unlink_source_link_failed` catalog string, never a raw server or browser message. When a slot carries more links than the serialized preview, the submenu loads the complete list through GET `/api/chat/slots/{slot}/source-links`. @@ -5246,7 +5246,7 @@ polled by this watchdog. **Session list visibility** (`pages/chat/page/paneLayout.tsx`, `pages/chat/page/sessionsDrawer.ts`): the desktop session list's shown or hidden choice is stored in `localStorage('mc-sidebar-pinned')`, and only the user's toggle writes it. With no sessions the list is forced open so the surface that creates one is on screen, but only after the first slot list has landed (`dashboard.slotsLoaded`): a page load starts with an empty list that means "not loaded yet", not "no sessions", so a stored hidden choice survives a refresh. The forced open is live only and is never persisted. -**Session titles** — auto-generated after a few turns via background LLM call in `chat_title.py` (`_maybe_auto_title`), pushed to all clients via `slot_title` WS/SSE event, persisted in chat history JSONL metadata via `ConversationLog.update_metadata()` together with `title_origin` (`auto` = background titler, `user` = manual rename; a legacy title with no stored origin rehydrates as `user`). Title input scans reserve a bounded allowance for the dashboard's 20-file upload limit, remove generated image/file references, and then cap retained user text before prompting or fallback selection. Non-image metadata is validated, length-limited, and stored in token-index order so each generated reference resolves directly without scanning every path. Manual trigger via `POST /api/chat/slots/{slot}/generate-title` — it prompts from the *last* `_TITLE_PROMPT_WINDOW` conversational (user/assistant) messages (the user regenerates because the name no longer fits, so the prompt must reflect the current topic, not the opening turns that initial titling reads; filtering before slicing keeps a tool-heavy tail from starving the window); generation errors use the same sanitized first-user-message fallback, while attachment-only placeholder results remain untitled so a later automatic attempt can retry. Cancellation releases the in-flight guard without starting a pending retry. Max 5 auto-title attempts before falling back to the first usable user message. **Background title refresh** (`chat_title.py:maybe_refresh_title`, fired from `chat_done` for already-titled slots): an `auto`-origin title is re-examined at the user-turn milestones in `_TITLE_REFRESH_MILESTONES` (8 and 24) through the same `_bg` one-liner path, with a `KEEP` reply leaving the name alone; each milestone fires at most once (attempt-counted — KEEP/error consumes it) and the consumed mark is persisted as `title_refresh_mark` so restarts cannot re-spend the budget. `dashboard.title_refresh_every_turns` (default `0`, loader-bounded to `0` or 4-1000) replaces the two built-in milestones with a cadence of N, 2N, 3N, ... user turns (`_title_refresh_due`; the low-signal early milestone still applies). Settings → Chat → Sessions edits it with a plain +/− stepper like Message Font Size (`ChatPanel.tsx`; `0` reads as a localized "Default" and disables −, + from it goes to 4, − from 4 goes back to it) through the `PATCH /api/config/kirocrew` allowlist, whose int entry admits the loader's whole domain, `0`-1000: a stored 1-3 is raised to 4 by the loader, the same as one `kirocrew config set` wrote, and the row reads back the loaded value from `GET /api/config/kirocrew`, so it shows the cadence that runs. It is read per turn off the event loop through the mtime-cached config load (`_title_refresh_every`, which answers `0` on any read failure so a broken config falls back to the built-in schedule). The turn count is read before that thread hop, so a follow-up message landing during it is not counted, and `_refresh_blocked` runs again after it, so a rename or another refresh landing during it stands the attempt down before it consumes the milestone or spends the call. It is attempt-counted through the same `title_refresh_mark`, so crossing several multiples at once costs one refresh. The turn count is taken over `slot.messages`, the rows held for the session, and counts a user row with `chat_title._counts_as_user_turn`: a row an app sent through `permissions.sessionApproval` (meta turn actor `app`) counts only on that app's own slot, so on a user's session it neither counts toward the auto-title attempts, the milestones or the cadence nor, together with the rows that answer it up to the next counted user row, reaches the auto-title or refresh prompt, their fallback, the low-signal check or the answered-turn check that locks a fallback (`_titling_messages`; see [app-kit-platform](app-kit-platform.md)). The manual `generate-title` trigger is the user's own request and reads every row. A merge run of queued messages stops where the stamped turn actor changes, so the user's queued words never land in an app-stamped row. When a session is reloaded, by a gateway restart (`_rehydrate_slot_from_history`, `_apply_recent_session`) or by reopening it from History (`chat_handlers._hydrate_slot_from_history`, which the transfer import shares), every loader holds the latest 500 rows plus the new ones, reconciles the local-turn marker (`_reconcile_local_turn_marker`, which can re-append an opening row the flush never wrote), and then calls `chat_persistence._rebase_rehydrated_refresh_mark`, which counts with the same predicate, so `chat_title._rehydrated_refresh_mark` re-bases a persisted mark above the restored user count down to that count, never below the largest fixed milestone at or below the persisted mark across (`_TITLE_EARLY_REFRESH_MILESTONE`, `*_TITLE_REFRESH_MILESTONES`), or 0 when none, so a spent fixed milestone stays spent, and the cadence continues from the re-based mark, at the next multiple of N above it (a lazy re-base in `maybe_refresh_title` would fire on every dip of the front-trimmed count). When the reload keeps fewer user turns than the largest fixed milestone the session had already reached, that floor holds the mark above the restored count and the cadence resumes after the milestone, not after the restored count: with mark 24, 20 restored user turns and N=4, the next refresh is at turn 28, not 24. Import hydrates the same 500-row window, having written the older rows to the transcript as its frozen prefix, so it re-bases the same way. `_ChatSlot.append` front-trims the window at `_MAX_SLOT_MESSAGES` (10,000), so each new row drops the oldest, the count rises only while the dropped rows are not user turns, and refreshes slow down or stop until the next reload. Each refresh is one background call, which is why the setting is opt-in. A manual rename records origin `user`, which locks the refresh out permanently, and bumps `_title_epoch` so an in-flight generation attempt stands down instead of clobbering the rename (the reveal animation is cosmetic-only and never assigns `slot.title`). A session closed and reopened under the same key is a NEW slot with its own epoch, so the epoch alone cannot tell it from the original: the first-message auto-title and the milestone refresh also stand down when `state._slots.get(slot.key)` is no longer that slot, checked after generation, after the reveal and after the persist, and `_reveal_title` stops on the same check. The check is also handed to `_persist_title` as `still_current`, which refuses the write under the transcript lock, and `title_then_refresh` skips the refresh for a replaced slot, so a stale title is neither written under the history key its successor shares nor pushed to the successor's sidebar row. The epoch guard covers the FOREGROUND endpoint too, not only the background tasks: `api_chat_slot_generate_title` snapshots `_title_epoch` before its generation await and re-checks it at the two points `maybe_refresh_title` does — after the generation await, before writing `slot.title`, and after its own persist await, before `push_slot_title` — and on a mismatch it writes nothing and answers with the endpoint's empty-title "nothing was applied" shape (`{"ok": true, "title": ""}`), which the dashboard's own title control already treats as a no-op, so the name the user typed stays on screen. Two clients issuing a plain `PATCH …/title` concurrently remain last-writer-wins: a conditional write there needs a client-supplied version and a 409 path, which is an open API-contract decision. **Ticket key prefix** (`dashboard.title_ticket_prefix`, default `false`, read off the event loop through `_title_ticket_prefix_enabled`, which answers `False` on any read failure): the model's summary is wrapped deterministically as `KEY: summary`, never asked of the model, so the key cannot be hallucinated. `_ticket_key_from_messages` takes the first `_TITLE_TICKET_KEY_RE` match (a tracker key `[A-Z][A-Z0-9]{1,9}-\d{2,10}` or a GitHub `#N` not preceded by a word character, `/`, `&` or `#`) from the first user row of the titling messages, after attachment paths are stripped. `_with_ticket_prefix` leaves a summary that already contains the key unchanged. The initial LLM title, both truncated fallbacks and the manual regenerate are wrapped; the low-signal test reads the unwrapped summary. The refresh splits a leading `KEY: ` off the current title (`_split_ticket_prefix`) before prompting, so the model judges the summary alone, and re-applies the same key to a replacement, falling back to the opening-message key only when the title carries none. The flag is read before each generation await, so every write stays behind the existing race guards. Branch-name and per-session key sources are not implemented. The generated name follows the **workspace UI language** (`dashboard.language`) rather than the conversation's: `_ui_language()` resolves the tag via `context.ui_language_tag()` and `_build_title_prompt()` interpolates a language directive outside the delimited transcript, with `""` (auto) omitting it and leaving the prompt byte-identical — see `config.md` § Dashboard UI language for the prose-guard and reveal-animation consequences of naming in an unspaced script. +**Session titles** — auto-generated after a few turns via background LLM call in `chat_title.py` (`_maybe_auto_title`), pushed to all clients via `slot_title` WS/SSE event, persisted in chat history JSONL metadata via `ConversationLog.update_metadata()` together with `title_origin` (`auto` = background titler, `user` = manual rename; a legacy title with no stored origin rehydrates as `user`). Title input scans reserve a bounded allowance for the dashboard's 20-file upload limit, remove generated image/file references, and then cap retained user text before prompting or fallback selection. Non-image metadata is validated, length-limited, and stored in token-index order so each generated reference resolves directly without scanning every path. Manual trigger via `POST /api/chat/slots/{slot}/generate-title` — it prompts from the *last* `_TITLE_PROMPT_WINDOW` conversational (user/assistant) messages (the user regenerates because the name no longer fits, so the prompt must reflect the current topic, not the opening turns that initial titling reads; filtering before slicing keeps a tool-heavy tail from starving the window); generation errors use the same sanitized first-user-message fallback, while attachment-only placeholder results remain untitled so a later automatic attempt can retry. Cancellation releases the in-flight guard without starting a pending retry. Max 5 auto-title attempts before falling back to the first usable user message. **Background title refresh** (`chat_title.py:maybe_refresh_title`, fired from `chat_done` for already-titled slots): an `auto`-origin title is re-examined at the user-turn milestones in `_TITLE_REFRESH_MILESTONES` (8 and 24) through the same `_bg` one-liner path, with a `KEEP` reply leaving the name alone; each milestone fires at most once (attempt-counted — KEEP/error consumes it) and the consumed mark is persisted as `title_refresh_mark` so restarts cannot re-spend the budget. `dashboard.title_refresh_every_turns` (default `0`, loader-bounded to `0` or 4-1000) replaces the two built-in milestones with a cadence of N, 2N, 3N, ... user turns (`_title_refresh_due`; the low-signal early milestone still applies). Settings → Chat → Sessions edits it with a plain +/− stepper like Message Font Size (`ChatPanel.tsx`; `0` reads as a localized "Default" and disables −, + from it goes to 4, − from 4 goes back to it) through the `PATCH /api/config/kirocrew` allowlist, whose int entry admits the loader's whole domain, `0`-1000: a stored 1-3 is raised to 4 by the loader, the same as one `kirocrew config set` wrote, and the row reads back the loaded value from `GET /api/config/kirocrew`, so it shows the cadence that runs. It is read per turn off the event loop through the mtime-cached config load (`_title_refresh_every`, which answers `0` on any read failure so a broken config falls back to the built-in schedule). The turn count is read before that thread hop, so a follow-up message landing during it is not counted, and `_refresh_blocked` runs again after it, so a rename or another refresh landing during it stands the attempt down before it consumes the milestone or spends the call. It is attempt-counted through the same `title_refresh_mark`, so crossing several multiples at once costs one refresh. The turn count is taken over `slot.messages`, the rows held for the session, and counts a user row with `chat_title._counts_as_user_turn`: a row an app sent through `permissions.sessionApproval` (meta turn actor `app`) counts only on that app's own slot, so on a user's session it neither counts toward the auto-title attempts, the milestones or the cadence nor, together with the rows that answer it up to the next counted user row, reaches the auto-title or refresh prompt, their fallback, the low-signal check or the answered-turn check that locks a fallback (`_titling_messages`; see [app-kit-platform](app-kit-platform.md)). The manual `generate-title` trigger is the user's own request and reads every row. A merge run of queued messages stops where the stamped turn actor changes, so the user's queued words never land in an app-stamped row. When a session is reloaded, by a gateway restart (`_rehydrate_slot_from_history`, `_apply_recent_session`) or by reopening it from History (`chat_handlers._hydrate_slot_from_history`, which the transfer import shares), every loader holds the latest 500 rows plus the new ones, reconciles the local-turn marker (`_reconcile_local_turn_marker`, which can re-append an opening row the flush never wrote), and then calls `chat_persistence._rebase_rehydrated_refresh_mark`, which counts with the same predicate, so `chat_title._rehydrated_refresh_mark` re-bases a persisted mark above the restored user count down to that count, never below the largest fixed milestone at or below the persisted mark across (`_TITLE_EARLY_REFRESH_MILESTONE`, `*_TITLE_REFRESH_MILESTONES`), or 0 when none, so a spent fixed milestone stays spent, and the cadence continues from the re-based mark, at the next multiple of N above it (a lazy re-base in `maybe_refresh_title` would fire on every dip of the front-trimmed count). When the reload keeps fewer user turns than the largest fixed milestone the session had already reached, that floor holds the mark above the restored count and the cadence resumes after the milestone, not after the restored count: with mark 24, 20 restored user turns and N=4, the next refresh is at turn 28, not 24. Import hydrates the same 500-row window, having written the older rows to the transcript as its frozen prefix, so it re-bases the same way. `_ChatSlot.append` front-trims the window at `_MAX_SLOT_MESSAGES` (10,000), so each new row drops the oldest, the count rises only while the dropped rows are not user turns, and refreshes slow down or stop until the next reload. Each refresh is one background call, which is why the setting is opt-in. A manual rename records origin `user`, which locks the refresh out permanently, and bumps `_title_epoch` so an in-flight generation attempt stands down instead of clobbering the rename (the reveal animation is cosmetic-only and never assigns `slot.title`). A session closed and reopened under the same key is a NEW slot with its own epoch, so the epoch alone cannot tell it from the original: the first-message auto-title and the milestone refresh also stand down when `state._slots.get(slot.key)` is no longer that slot, checked after generation, after the reveal and after the persist, and `_reveal_title` stops on the same check. The check is also handed to `_persist_title` as `still_current`, which refuses the write under the transcript lock, and `title_then_refresh` skips the refresh for a replaced slot, so a stale title is neither written under the history key its successor shares nor pushed to the successor's sidebar row. The epoch guard covers the FOREGROUND endpoint too, not only the background tasks: `api_chat_slot_generate_title` snapshots `_title_epoch` before its generation await and re-checks it at the two points `maybe_refresh_title` does — after the generation await, before writing `slot.title`, and after its own persist await, before `push_slot_title` — and on a mismatch it writes nothing and answers with the endpoint's empty-title "nothing was applied" shape (`{"ok": true, "title": ""}`), which the dashboard's own title control already treats as a no-op, so the name the user typed stays on screen. **Bulk regenerate** (`chat_title_bulk.py`, `POST /api/chat/generate-titles`) plans every live slot that is not closing, not under construction and not a crewmate DM thread (`_is_member_thread`, whose title the background paths never touch), and skips, in order, a running slot, a `user`-origin title unless `include_renamed`, a slot with no user or assistant row in `sidebar_messages()` (the restore tail, so a pending window is not loaded to plan), and a slot with no `GENERATE_TITLE` budget left (`create_rate_limit`, one call per target per 5 minutes). That budget is shared with the agent `session_generate_title` verb, so a bulk run makes that verb answer 429 `title_rate_limited` on the same slots for 5 minutes, while the per-row Regenerate never spends it. A `dry_run` spends nothing. Apply spends each queued slot's budget with `allow_create` and runs a background task that generates at most `BULK_TITLE_CONCURRENCY` (2) titles at once through `_model_manual_title` and `apply_regenerated_title` (no first-message fallback, so a model error counts the slot `failed` and leaves its title alone); each slot snapshots `_title_epoch` when the run starts, stands down if it is running, renamed or replaced by the time its turn comes, loads its window with `load_window_off_loop` and stands down on the same epoch and replaced-slot checks as the foreground endpoint, and titles reach clients through the usual `slot_title` push. The run's progress lives in `state._bulk_title_run` (`_BulkTitleRun`: `id`, `total`, and `retitled`, `unchanged` and `failed` counts whose sum is `done`, plus `failed_sessions`, the `{key, title}` of each failed slot, which the panel lists as links that open that session; a slot that stood down before its save or got an empty title counts as `unchanged`, and one whose save failed counts as `failed`, including a slot closed or replaced while its title was being saved). `GET /api/chat/generate-titles` returns it as `{run}` (null before the first run, dashboard users only), the panel polls it every second while the run is unfinished, and an apply while a run is unfinished answers 409 `bulk_titles_in_progress` so a second click cannot spend budget twice. Both routes refuse an app token with 403 and log each refusal to SEL with `source="app_isolation"`. Two clients issuing a plain `PATCH …/title` concurrently remain last-writer-wins: a conditional write there needs a client-supplied version and a 409 path, which is an open API-contract decision. **Ticket key prefix** (`dashboard.title_ticket_prefix`, default `false`, read off the event loop through `_title_ticket_prefix_enabled`, which answers `False` on any read failure): the model's summary is wrapped deterministically as `KEY: summary`, never asked of the model, so the key cannot be hallucinated. `_ticket_key_from_messages` takes the first `_TITLE_TICKET_KEY_RE` match (a tracker key `[A-Z][A-Z0-9]{1,9}-\d{2,10}` or a GitHub `#N` not preceded by a word character, `/`, `&` or `#`) from the first user row of the titling messages, after attachment paths are stripped. `_with_ticket_prefix` leaves a summary that already contains the key unchanged. The initial LLM title, both truncated fallbacks and the manual regenerate are wrapped; the low-signal test reads the unwrapped summary. The refresh splits a leading `KEY: ` off the current title (`_split_ticket_prefix`) before prompting, so the model judges the summary alone, and re-applies the same key to a replacement, falling back to the opening-message key only when the title carries none. The flag is read before each generation await, so every write stays behind the existing race guards. Branch-name and per-session key sources are not implemented. The generated name follows the **workspace UI language** (`dashboard.language`) rather than the conversation's: `_ui_language()` resolves the tag via `context.ui_language_tag()` and `_build_title_prompt()` interpolates a language directive outside the delimited transcript, with `""` (auto) omitting it and leaving the prompt byte-identical — see `config.md` § Dashboard UI language for the prose-guard and reveal-animation consequences of naming in an unspaced script. **Closing a session vs browser history**: while a close's DELETE is unanswered, the session controller holds back the one URL write it would otherwise make — the history entry still names the closing `sid` and stands under the landing session the close switched to (`closeLandingRef`) — and keeps that latch through in-flight, confirmed and retired, dropping it once the URL moves off that `sid` or the key is live again. A Back onto an entry naming a dying session lands the same way. diff --git a/src/kiro_crew/dashboard/chat.py b/src/kiro_crew/dashboard/chat.py index c74ef4074ae..13a3fcc7327 100644 --- a/src/kiro_crew/dashboard/chat.py +++ b/src/kiro_crew/dashboard/chat.py @@ -162,6 +162,10 @@ api_chat_slot_generate_title, api_chat_slot_rename, ) +from kiro_crew.dashboard.chat_title_bulk import ( # noqa: F401 + api_chat_slots_generate_titles, + api_chat_slots_generate_titles_status, +) from kiro_crew.dashboard.chat_todo import api_chat_slot_todo # noqa: F401 from kiro_crew.dashboard.chat_utils import ( # noqa: F401 _BLOCKED_SLASH_COMMANDS, diff --git a/src/kiro_crew/dashboard/chat_title.py b/src/kiro_crew/dashboard/chat_title.py index 44908d63a6e..abe5f15c47a 100644 --- a/src/kiro_crew/dashboard/chat_title.py +++ b/src/kiro_crew/dashboard/chat_title.py @@ -1716,6 +1716,27 @@ def superseded() -> bool: slot._title_in_flight = False +async def _model_manual_title(state: DashboardState, slot: _ChatSlot) -> str: + # Window the RECENT conversational tail: the user reaches for + # "Regenerate title" when the current name doesn't fit, so the + # prompt must be built from what the session is about NOW, not its + # opening turns. Filter to the roles the prompt builder keeps BEFORE + # slicing — the raw list interleaves tool/permission/status rows, so + # a raw tail over a tool-heavy turn could window every usable line + # out and leave the click a silent no-op. The prompt builder's head + # slice is a no-op on this pre-windowed tail, so the initial + # auto-title path (which passes the full list and wants the opening + # messages) is unaffected. + convo = [m for m in slot.messages if m.get("role") in _TITLE_PROMPT_ROLES] + # Same label as the automatic paths: the key the title already leads + # with, else the opening message's. + key = await _ticket_key_if_enabled(_current_ticket_key(slot.title, convo)) + title = await _generate_title_via_kiro( + state, convo[-_TITLE_PROMPT_WINDOW:], session_key=effective_session_key(slot) + ) + return _with_ticket_prefix(title, key) + + async def generate_manual_title(state: DashboardState, slot: _ChatSlot) -> str: """The title "Regenerate title" would give *slot* now, or ``""`` for none. @@ -1728,24 +1749,7 @@ async def generate_manual_title(state: DashboardState, slot: _ChatSlot) -> str: if _is_member_thread(slot): return "" try: - # Window the RECENT conversational tail: the user reaches for - # "Regenerate title" when the current name no longer fits, so the - # prompt must be built from what the session is about NOW, not its - # opening turns. Filter to the roles the prompt builder keeps BEFORE - # slicing — the raw list interleaves tool/permission/status rows, so - # a raw tail over a tool-heavy turn could window every usable line - # out and leave the click a silent no-op. The prompt builder's head - # slice is a no-op on this pre-windowed tail, so the initial - # auto-title path (which passes the full list and wants the opening - # messages) is unaffected. - convo = [m for m in slot.messages if m.get("role") in _TITLE_PROMPT_ROLES] - # Same label as the automatic paths: the key the title already leads - # with, else the opening message's. - key = await _ticket_key_if_enabled(_current_ticket_key(slot.title, convo)) - title = await _generate_title_via_kiro( - state, convo[-_TITLE_PROMPT_WINDOW:], session_key=effective_session_key(slot) - ) - return _with_ticket_prefix(title, key) + return await _model_manual_title(state, slot) except Exception: logger.debug("Title generation failed for slot %s", slot.key, exc_info=True) title = _fallback_title_from_messages(slot.messages) diff --git a/src/kiro_crew/dashboard/chat_title_bulk.py b/src/kiro_crew/dashboard/chat_title_bulk.py new file mode 100644 index 00000000000..b8badebead3 --- /dev/null +++ b/src/kiro_crew/dashboard/chat_title_bulk.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import asyncio +import itertools +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from aiohttp import web + +from kiro_crew.dashboard.chat_title import ( + _TITLE_ORIGIN_USER, + _TITLE_PROMPT_ROLES, + _is_member_thread, + _model_manual_title, + apply_regenerated_title, +) +from kiro_crew.dashboard.chat_utils_display import _redact_for_display +from kiro_crew.dashboard.create_rate_limit import GENERATE_TITLE, allow_create, has_create_budget +from kiro_crew.dashboard.handlers._shared import read_bounded_json +from kiro_crew.dashboard.state import load_window_off_loop +from kiro_crew.sel import sel + +if TYPE_CHECKING: + from kiro_crew.dashboard.state import DashboardState + +logger = logging.getLogger(__name__) + +BULK_TITLE_CONCURRENCY = 2 + +_run_ids = itertools.count(1) + +_RETITLED = "retitled" +_UNCHANGED = "unchanged" +_FAILED = "failed" + + +@dataclass +class _BulkTitleRun: + id: int + total: int + retitled: int = 0 + unchanged: int = 0 + failed: int = 0 + failed_sessions: list[dict[str, str]] = field(default_factory=list) + + def record_failure(self, slot: Any) -> None: + self.failed += 1 + title = _redact_for_display(slot.title or "") + self.failed_sessions.append({"key": slot.key, "title": title}) + + @property + def done(self) -> int: + return self.retitled + self.unchanged + self.failed + + @property + def finished(self) -> bool: + return self.done >= self.total + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, + "total": self.total, + "done": self.done, + "retitled": self.retitled, + "unchanged": self.unchanged, + "failed": self.failed, + "failed_sessions": list(self.failed_sessions), + "finished": self.finished, + } + + +def _current_run(state: DashboardState) -> _BulkTitleRun | None: + run = getattr(state, "_bulk_title_run", None) + return run if isinstance(run, _BulkTitleRun) else None + + +@dataclass +class _BulkTitlePlan: + queued: list[Any] = field(default_factory=list) + skipped_running: list[str] = field(default_factory=list) + skipped_renamed: list[str] = field(default_factory=list) + skipped_recent: list[str] = field(default_factory=list) + skipped_empty: list[str] = field(default_factory=list) + renamed: int = 0 + + def to_json(self, *, dry_run: bool, run: _BulkTitleRun | None = None) -> dict[str, Any]: + return { + "ok": True, + "dry_run": dry_run, + "run_id": run.id if run is not None else None, + "queued": [slot.key for slot in self.queued], + "skipped_running": self.skipped_running, + "skipped_renamed": self.skipped_renamed, + "skipped_recent": self.skipped_recent, + "skipped_empty": self.skipped_empty, + "renamed": self.renamed, + } + + +def _is_busy(slot: Any) -> bool: + return bool(slot.running or slot.linked_turn_running) + + +def _has_conversation(slot: Any) -> bool: + return any(m.get("role") in _TITLE_PROMPT_ROLES for m in slot.sidebar_messages()) + + +def plan_bulk_titles(state: DashboardState, *, include_renamed: bool) -> _BulkTitlePlan: + plan = _BulkTitlePlan() + under_construction = _under_construction(state) + for key, slot in list(state._slots.items()): + if slot.is_closing or key in under_construction or _is_member_thread(slot): + continue + renamed = slot._title_origin == _TITLE_ORIGIN_USER + if _is_busy(slot): + plan.skipped_running.append(key) + continue + if renamed: + plan.renamed += 1 + if not include_renamed: + plan.skipped_renamed.append(key) + continue + if not _has_conversation(slot): + plan.skipped_empty.append(key) + elif not has_create_budget(GENERATE_TITLE, key): + plan.skipped_recent.append(key) + else: + plan.queued.append(slot) + return plan + + +def _under_construction(state: DashboardState) -> Any: + return getattr(state, "_slots_under_construction", None) or () + + +def _still_current(state: DashboardState, slot: Any) -> bool: + return ( + state._slots.get(slot.key) is slot + and not slot.is_closing + and slot.key not in _under_construction(state) + ) + + +async def _retitle_one( + state: DashboardState, + slot: Any, + gate: asyncio.Semaphore, + *, + epoch: int, + include_renamed: bool, +) -> str: + async with gate: + if slot._title_epoch != epoch or not _still_current(state, slot) or _is_busy(slot): + return _UNCHANGED + if _is_member_thread(slot): + return _UNCHANGED + if not include_renamed and slot._title_origin == _TITLE_ORIGIN_USER: + return _UNCHANGED + await load_window_off_loop(slot) + if slot._title_epoch != epoch or not _still_current(state, slot): + return _UNCHANGED + title = await _model_manual_title(state, slot) + if not title or slot._title_epoch != epoch or not _still_current(state, slot): + return _UNCHANGED + previous = (slot.title, slot._titled, slot._title_origin, slot._title_low_signal) + applied_epoch = slot._title_epoch + 1 + saved = await apply_regenerated_title( + state, slot, title, still_current=lambda: _still_current(state, slot) + ) + if saved: + return _RETITLED + if slot._title_epoch == applied_epoch and state._slots.get(slot.key) is slot: + slot.title, slot._titled, slot._title_origin, slot._title_low_signal = previous + slot._title_epoch += 1 + state.push_slot_title(slot.key, slot.title) + return _FAILED + + +async def _tracked( + state: DashboardState, + run: _BulkTitleRun, + slot: Any, + gate: asyncio.Semaphore, + *, + epoch: int, + include_renamed: bool, +) -> None: + try: + outcome = await _retitle_one( + state, slot, gate, epoch=epoch, include_renamed=include_renamed + ) + except asyncio.CancelledError: + run.record_failure(slot) + raise + except Exception: + run.record_failure(slot) + logger.warning("Bulk title regeneration failed for slot %s", slot.key, exc_info=True) + return + if outcome == _RETITLED: + run.retitled += 1 + elif outcome == _FAILED: + run.record_failure(slot) + else: + run.unchanged += 1 + + +async def run_bulk_titles( + state: DashboardState, + slots: list[Any], + *, + include_renamed: bool, + run: _BulkTitleRun | None = None, + epochs: dict[str, int] | None = None, +) -> None: + if run is None: + run = _BulkTitleRun(id=next(_run_ids), total=len(slots)) + if epochs is None: + epochs = {slot.key: slot._title_epoch for slot in slots} + gate = asyncio.Semaphore(BULK_TITLE_CONCURRENCY) + await asyncio.gather( + *( + _tracked( + state, run, slot, gate, epoch=epochs[slot.key], include_renamed=include_renamed + ) + for slot in slots + ), + return_exceptions=True, + ) + + +def _refusal(request: web.Request, operation: str) -> web.Response: + sel().log_api_access( + caller=str(request.get("app") or "unknown"), + operation=operation, + outcome="denied", + source="app_isolation", + resources="bulk_titles", + error="only a dashboard user can regenerate titles", + ) + return web.json_response({"error": "unauthorized", "code": "forbidden"}, status=403) + + +async def api_chat_slots_generate_titles_status(request: web.Request) -> web.Response: + if request.get("app") != "": + return _refusal(request, "chat.slots_generate_titles_status") + run = _current_run(request.app["state"]) + return web.json_response({"ok": True, "run": run.to_json() if run is not None else None}) + + +async def api_chat_slots_generate_titles(request: web.Request) -> web.Response: + if request.get("app") != "": + return _refusal(request, "chat.slots_generate_titles") + body, body_err = await read_bounded_json(request) + if body_err is not None: + return body_err + assert body is not None + include_renamed = body.get("include_renamed", False) + dry_run = body.get("dry_run", False) + if not isinstance(include_renamed, bool): + return web.json_response( + {"error": "include_renamed must be a boolean", "code": "invalid_include_renamed"}, + status=400, + ) + if not isinstance(dry_run, bool): + return web.json_response( + {"error": "dry_run must be a boolean", "code": "invalid_dry_run"}, status=400 + ) + + state: DashboardState = request.app["state"] + active = _current_run(state) + if not dry_run and active is not None and not active.finished: + return web.json_response( + { + "error": "bulk_titles_in_progress", + "code": "bulk_titles_in_progress", + "run": active.to_json(), + }, + status=409, + ) + plan = plan_bulk_titles(state, include_renamed=include_renamed) + run: _BulkTitleRun | None = None + if not dry_run: + admitted = [] + for slot in plan.queued: + if allow_create(GENERATE_TITLE, slot.key): + admitted.append(slot) + else: + plan.skipped_recent.append(slot.key) + plan.queued = admitted + if admitted: + logger.info("Bulk title regeneration queued for %d sessions", len(admitted)) + sel().log_api_access( + caller=str(request.get("user") or "dashboard"), + operation="chat.slots_generate_titles", + outcome="success", + source="dashboard", + resources=f"bulk_titles:{len(admitted)}", + ) + run = _BulkTitleRun(id=next(_run_ids), total=len(admitted)) + state._bulk_title_run = run + epochs = {slot.key: slot._title_epoch for slot in admitted} + task = asyncio.create_task( + run_bulk_titles( + state, admitted, include_renamed=include_renamed, run=run, epochs=epochs + ) + ) + state._background_tasks.add(task) + task.add_done_callback(state._background_tasks.discard) + return web.json_response(plan.to_json(dry_run=dry_run, run=run)) diff --git a/src/kiro_crew/dashboard/routes/chat.py b/src/kiro_crew/dashboard/routes/chat.py index c42e84df81b..df81fc49485 100644 --- a/src/kiro_crew/dashboard/routes/chat.py +++ b/src/kiro_crew/dashboard/routes/chat.py @@ -109,6 +109,8 @@ def register(app: web.Application) -> None: app.router.add_post( "/api/chat/slots/follow-default", chat_follow_default.api_chat_slots_follow_default ) + app.router.add_post("/api/chat/generate-titles", chat.api_chat_slots_generate_titles) + app.router.add_get("/api/chat/generate-titles", chat.api_chat_slots_generate_titles_status) # Static segment BEFORE the ``/api/chat/slots/{slot}`` GET below, for the reason # the import route's own note gives: aiohttp resolves in registration order, so a # later wildcard would shadow this path and answer it as a slot detail read. diff --git a/src/kiro_crew/dashboard/state_parts/dashboard_state.py b/src/kiro_crew/dashboard/state_parts/dashboard_state.py index f599e7e6257..fc51837c9a4 100644 --- a/src/kiro_crew/dashboard/state_parts/dashboard_state.py +++ b/src/kiro_crew/dashboard/state_parts/dashboard_state.py @@ -669,6 +669,7 @@ def __init__( # hand-edited-but-typo'd column. self._unparsed_tag_board_entries: list[Any] = [] self._background_tasks: set[asyncio.Task] = set() # type: ignore[type-arg] + self._bulk_title_run: Any = None self._dynamic_cards: Any = None # Gateway replacement is process-wide, not an ordinary repeatable # background mutation. The task latch coalesces duplicate /api/restart diff --git a/src/kiro_crew/security_posture.py b/src/kiro_crew/security_posture.py index 848b8ba3d51..4b072f910c6 100644 --- a/src/kiro_crew/security_posture.py +++ b/src/kiro_crew/security_posture.py @@ -687,6 +687,7 @@ "dashboard/slot_persistence/metadata_values.py", "dashboard/chat_regenerate.py", "dashboard/chat_title.py", + "dashboard/chat_title_bulk.py", "dashboard/chat_utils.py", # The display-redaction owner served by the ``dashboard/chat_utils.py`` facade: # the same redactor calls, moved verbatim, under the facade's classification. diff --git a/test/test_chat_title.py b/test/test_chat_title.py index 8cca1efe58f..6e7511cfd77 100644 --- a/test/test_chat_title.py +++ b/test/test_chat_title.py @@ -433,6 +433,25 @@ async def _fake_oneliner(*_a, **_kw): assert title == "Kiro Crew launch blog" +@pytest.mark.asyncio +async def test_manual_title_falls_back_to_the_opening_message_when_the_model_fails(monkeypatch): + async def _model_down(*_a, **_kw): + raise TimeoutError("titling model timed out") + + async def _no_key(_key): + return "" + + monkeypatch.setattr(chat_title, "_generate_title_via_kiro", _model_down) + monkeypatch.setattr(chat_title, "_ticket_key_if_enabled", _no_key) + slot = _ChatSlot("s") + slot.messages.append({"role": "user", "content": "plan the launch blog"}) + + title = await chat_title.generate_manual_title(SimpleNamespace(), slot) + + assert title == chat_title._fallback_title_from_messages(slot.messages) + assert title + + @pytest.mark.asyncio async def test_discarded_prose_is_redacted_before_it_is_logged(monkeypatch, caplog): """A refusal can quote the user's message back, credentials included. diff --git a/test/test_chat_title_bulk.py b/test/test_chat_title_bulk.py new file mode 100644 index 00000000000..205c544bf7a --- /dev/null +++ b/test/test_chat_title_bulk.py @@ -0,0 +1,721 @@ +from __future__ import annotations + +import asyncio +import threading +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer, make_mocked_request + +from kiro_crew.dashboard import chat_title, chat_title_bulk, create_rate_limit +from kiro_crew.dashboard.chat_title_bulk import ( + api_chat_slots_generate_titles, + api_chat_slots_generate_titles_status, + plan_bulk_titles, + run_bulk_titles, +) +from kiro_crew.dashboard.create_rate_limit import GENERATE_TITLE, allow_create, has_create_budget +from kiro_crew.dashboard.state import _ChatSlot +from kiro_crew.members import DM_SLOT_MODE +from kiro_crew.testing.wait import await_until + + +@pytest.fixture(autouse=True) +def _fresh_budget(): + create_rate_limit.reset_for_tests() + yield + create_rate_limit.reset_for_tests() + + +def _slot(key: str, *, said: bool = True, origin: str = "auto", running: bool = False) -> _ChatSlot: + slot = _ChatSlot(key) + if said: + slot.messages.append({"role": "user", "content": f"about {key}"}) + slot._title_origin = origin + slot._turn_admission_reserved = running + return slot + + +def _state(*slots: _ChatSlot) -> SimpleNamespace: + return SimpleNamespace(_slots={s.key: s for s in slots}, _background_tasks=set()) + + +class _Recorder: + def __init__(self) -> None: + self.applied: dict[str, str] = {} + self.active = 0 + self.peak = 0 + self.on_generate = None + + async def generate(self, state, slot): + self.active += 1 + self.peak = max(self.peak, self.active) + try: + await asyncio.sleep(0) + if self.on_generate is not None: + self.on_generate(state, slot) + return f"New {slot.key}" + finally: + self.active -= 1 + + async def apply(self, state, slot, title, *, still_current=None): + if still_current is not None and not still_current(): + return False + self.applied[slot.key] = title + return True + + +@pytest.fixture +def audit(monkeypatch) -> list[dict]: + rows: list[dict] = [] + + class _Sel: + def log_api_access(self, **kwargs): + rows.append({k: kwargs[k] for k in ("caller", "operation", "outcome", "source")}) + + monkeypatch.setattr(chat_title_bulk, "sel", lambda: _Sel()) + return rows + + +@pytest.fixture +def recorder(monkeypatch) -> _Recorder: + rec = _Recorder() + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", rec.generate) + monkeypatch.setattr(chat_title_bulk, "apply_regenerated_title", rec.apply) + return rec + + +def _app(state, *, caller: str | None = "") -> web.Application: + @web.middleware + async def as_caller(request, handler): + if caller is not None: + request["app"] = caller + return await handler(request) + + app = web.Application(middlewares=[as_caller]) + app["state"] = state + app.router.add_post("/api/chat/generate-titles", api_chat_slots_generate_titles) + app.router.add_get("/api/chat/generate-titles", api_chat_slots_generate_titles_status) + return app + + +async def _drain(state) -> None: + while state._background_tasks: + await await_until( + asyncio.gather(*list(state._background_tasks)), what="the bulk title run to finish" + ) + + +class TestPlan: + def test_each_session_lands_in_one_bucket(self): + state = _state( + _slot("ready"), + _slot("busy", running=True), + _slot("named", origin="user"), + _slot("blank", said=False), + _slot("recent"), + ) + assert allow_create(GENERATE_TITLE, "recent") + + plan = plan_bulk_titles(state, include_renamed=False) + + assert [s.key for s in plan.queued] == ["ready"] + assert plan.skipped_running == ["busy"] + assert plan.skipped_renamed == ["named"] + assert plan.skipped_empty == ["blank"] + assert plan.skipped_recent == ["recent"] + assert plan.renamed == 1 + + def test_include_renamed_queues_a_hand_set_title(self): + state = _state(_slot("named", origin="user")) + + plan = plan_bulk_titles(state, include_renamed=True) + + assert [s.key for s in plan.queued] == ["named"] + assert plan.skipped_renamed == [] + assert plan.renamed == 1 + + def test_a_running_renamed_session_counts_as_running_only(self): + state = _state(_slot("busy-named", origin="user", running=True)) + + plan = plan_bulk_titles(state, include_renamed=False) + + assert plan.skipped_running == ["busy-named"] + assert plan.skipped_renamed == [] + assert plan.renamed == 0 + + def test_a_channel_session_answering_on_the_channel_path_counts_as_running(self): + replying = _slot("replying") + replying.linked_session_key = "slack:123.456" + replying._linked_session_busy = lambda key: key == "slack:123.456" + state = _state(replying) + + plan = plan_bulk_titles(state, include_renamed=False) + + assert plan.skipped_running == ["replying"] + assert plan.queued == [] + + def test_a_closing_session_is_left_out(self, monkeypatch): + closing = _slot("closing") + monkeypatch.setattr(type(closing), "is_closing", property(lambda s: s.key == "closing")) + state = _state(closing, _slot("open")) + + plan = plan_bulk_titles(state, include_renamed=False) + + assert [s.key for s in plan.queued] == ["open"] + + @pytest.mark.parametrize("include_renamed", [False, True]) + def test_a_crewmate_thread_is_left_out(self, include_renamed): + thread = _slot("thread", origin="member") + thread.mode = DM_SLOT_MODE + state = _state(thread, _slot("open")) + + plan = plan_bulk_titles(state, include_renamed=include_renamed) + + assert [s.key for s in plan.queued] == ["open"] + assert plan.to_json(dry_run=True)["skipped_empty"] == [] + assert plan.renamed == 0 + + +class TestRun: + @pytest.mark.asyncio + async def test_at_most_two_titles_generate_at_once(self, recorder): + slots = [_slot(f"s{i}") for i in range(6)] + state = _state(*slots) + + await run_bulk_titles(state, slots, include_renamed=False) + + assert recorder.peak == chat_title_bulk.BULK_TITLE_CONCURRENCY == 2 + assert set(recorder.applied) == {s.key for s in slots} + + @pytest.mark.asyncio + async def test_a_rename_during_generation_keeps_the_rename(self, recorder): + slot = _slot("s") + state = _state(slot) + + def renamed(_state, target): + target._title_epoch += 1 + + recorder.on_generate = renamed + + await run_bulk_titles(state, [slot], include_renamed=False) + + assert recorder.applied == {} + + @pytest.mark.asyncio + async def test_a_session_replaced_during_generation_keeps_its_title(self, recorder): + slot = _slot("s") + state = _state(slot) + + def replaced(state_, target): + state_._slots[target.key] = _slot(target.key) + + recorder.on_generate = replaced + + await run_bulk_titles(state, [slot], include_renamed=False) + + assert recorder.applied == {} + + @pytest.mark.asyncio + async def test_a_session_renamed_while_queued_is_skipped(self, recorder): + slot = _slot("s") + state = _state(slot) + slot._title_origin = "user" + + await run_bulk_titles(state, [slot], include_renamed=False) + + assert recorder.applied == {} + + @pytest.mark.asyncio + async def test_one_failure_does_not_stop_the_others(self, recorder, caplog): + good, bad = _slot("good"), _slot("bad") + state = _state(good, bad) + + def boom(_state, target): + if target.key == "bad": + raise RuntimeError("model down") + + recorder.on_generate = boom + + await run_bulk_titles(state, [bad, good], include_renamed=False) + + assert recorder.applied == {"good": "New good"} + assert "Bulk title regeneration failed for slot bad" in caplog.text + + @pytest.mark.asyncio + async def test_a_crewmate_thread_is_never_retitled(self, recorder): + thread = _slot("thread", origin="member") + thread.mode = DM_SLOT_MODE + + await run_bulk_titles(_state(thread), [thread], include_renamed=True) + + assert recorder.applied == {} + + @pytest.mark.asyncio + async def test_a_model_failure_keeps_the_title_and_counts_as_failed(self, monkeypatch): + async def model_down(*_args, **_kwargs): + raise TimeoutError("titling model timed out") + + async def no_key(_key): + return "" + + applied: list[str] = [] + + async def apply(state, slot, title, *, still_current=None): + applied.append(title) + return True + + monkeypatch.setattr(chat_title, "_generate_title_via_kiro", model_down) + monkeypatch.setattr(chat_title, "_ticket_key_if_enabled", no_key) + monkeypatch.setattr(chat_title_bulk, "apply_regenerated_title", apply) + slot = _slot("s") + slot.title = "Old name" + state = _state(slot) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + status = await (await client.get("/api/chat/generate-titles")).json() + + assert applied == [] + assert slot.title == "Old name" + assert status["run"]["failed"] == 1 + assert status["run"]["failed_sessions"] == [{"key": "s", "title": "Old name"}] + assert status["run"]["retitled"] == 0 + + @pytest.mark.asyncio + async def test_a_failed_sessions_title_is_redacted_like_the_sidebar(self, monkeypatch): + async def model_down(*_args, **_kwargs): + raise TimeoutError("titling model timed out") + + async def no_key(_key): + return "" + + monkeypatch.setattr(chat_title, "_generate_title_via_kiro", model_down) + monkeypatch.setattr(chat_title, "_ticket_key_if_enabled", no_key) + slot = _slot("s") + slot.title = "Deploy with AKIAIOSFODNN7EXAMPLE" + state = _state(slot) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + status = await (await client.get("/api/chat/generate-titles")).json() + + [failed] = status["run"]["failed_sessions"] + assert "AKIAIOSFODNN7EXAMPLE" not in failed["title"] + assert slot.title == "Deploy with AKIAIOSFODNN7EXAMPLE" + + @pytest.mark.asyncio + async def test_an_empty_title_changes_nothing(self, recorder, monkeypatch): + async def nothing(state, slot): + return "" + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", nothing) + slot = _slot("s") + + await run_bulk_titles(_state(slot), [slot], include_renamed=False) + + assert recorder.applied == {} + + +class TestRoute: + @pytest.mark.asyncio + async def test_dry_run_reports_the_plan_and_spends_nothing(self, recorder): + state = _state(_slot("a"), _slot("named", origin="user")) + async with TestClient(TestServer(_app(state))) as client: + resp = await client.post("/api/chat/generate-titles", json={"dry_run": True}) + body = await resp.json() + + assert resp.status == 200 + assert body["dry_run"] is True + assert body["queued"] == ["a"] + assert body["skipped_renamed"] == ["named"] + assert body["renamed"] == 1 + assert has_create_budget(GENERATE_TITLE, "a") + assert state._background_tasks == set() + assert recorder.applied == {} + + @pytest.mark.asyncio + async def test_apply_retitles_and_spends_each_sessions_budget(self, recorder): + state = _state(_slot("a"), _slot("b")) + async with TestClient(TestServer(_app(state))) as client: + first = await (await client.post("/api/chat/generate-titles", json={})).json() + await _drain(state) + second = await (await client.post("/api/chat/generate-titles", json={})).json() + + assert sorted(first["queued"]) == ["a", "b"] + assert recorder.applied == {"a": "New a", "b": "New b"} + assert second["queued"] == [] + assert sorted(second["skipped_recent"]) == ["a", "b"] + + @pytest.mark.asyncio + async def test_a_budget_spent_between_plan_and_apply_is_skipped(self, recorder, monkeypatch): + state = _state(_slot("a")) + monkeypatch.setattr(chat_title_bulk, "allow_create", lambda verb, key: False) + async with TestClient(TestServer(_app(state))) as client: + body = await (await client.post("/api/chat/generate-titles", json={})).json() + + assert body["queued"] == [] + assert body["skipped_recent"] == ["a"] + assert state._background_tasks == set() + + @pytest.mark.asyncio + async def test_a_rename_before_the_run_starts_stands(self, recorder, monkeypatch): + slot = _slot("a") + state = _state(slot) + real = chat_title_bulk.run_bulk_titles + + async def renamed_first(state_, slots, **kwargs): + slot._title_epoch += 1 + slot._title_origin = "user" + await real(state_, slots, **kwargs) + + monkeypatch.setattr(chat_title_bulk, "run_bulk_titles", renamed_first) + async with TestClient(TestServer(_app(state))) as client: + body = await ( + await client.post("/api/chat/generate-titles", json={"include_renamed": True}) + ).json() + await _drain(state) + + assert body["queued"] == ["a"] + assert recorder.applied == {} + + @pytest.mark.asyncio + @pytest.mark.parametrize("caller", ["some-app", None]) + async def test_only_a_dashboard_user_may_call_it(self, recorder, audit, caller): + state = _state(_slot("a")) + async with TestClient(TestServer(_app(state, caller=caller))) as client: + resp = await client.post("/api/chat/generate-titles", json={}) + + assert resp.status == 403 + assert has_create_budget(GENERATE_TITLE, "a") + assert audit == [ + { + "caller": caller or "unknown", + "operation": "chat.slots_generate_titles", + "outcome": "denied", + "source": "app_isolation", + } + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("field", ["include_renamed", "dry_run"]) + async def test_a_non_boolean_flag_is_refused(self, recorder, field): + state = _state(_slot("a")) + async with TestClient(TestServer(_app(state))) as client: + resp = await client.post("/api/chat/generate-titles", json={field: "yes"}) + + assert resp.status == 400 + assert has_create_budget(GENERATE_TITLE, "a") + + +class _PendingLoader: + def __init__(self, rows: list[dict]) -> None: + self.rows = rows + self.read_threads: list[int] = [] + + def read(self, slot: _ChatSlot) -> tuple[list[dict], list[set[str]]]: + self.read_threads.append(threading.get_ident()) + return list(self.rows), [] + + def __call__(self, slot: _ChatSlot, messages: list[dict], claims: list[set[str]]) -> None: + slot._messages = messages + + +def _pending_slot(key: str) -> tuple[_ChatSlot, _PendingLoader]: + slot = _slot(key, said=False) + rows = [{"role": "user", "content": f"about {key}"}] + loader = _PendingLoader(rows) + slot._lazy_window = loader + slot._lazy_tail = list(rows) + return slot, loader + + +class TestPendingWindow: + def test_plan_reads_the_restore_tail_without_loading(self): + slot, loader = _pending_slot("a") + plan = plan_bulk_titles(_state(slot), include_renamed=False) + assert [s.key for s in plan.queued] == ["a"] + assert slot.window_pending + assert loader.read_threads == [] + + def test_plan_counts_a_pending_slot_with_no_conversation_as_empty(self): + slot, loader = _pending_slot("a") + slot._lazy_tail = [] + plan = plan_bulk_titles(_state(slot), include_renamed=False) + assert plan.skipped_empty == ["a"] + assert loader.read_threads == [] + + @pytest.mark.asyncio + async def test_run_loads_the_window_off_the_loop(self, recorder): + slot, loader = _pending_slot("a") + loop_thread = threading.get_ident() + await run_bulk_titles(_state(slot), [slot], include_renamed=False) + assert loader.read_threads and loop_thread not in loader.read_threads + assert not slot.window_pending + assert recorder.applied == {"a": "New a"} + + +class TestQueued: + async def _hold_first_two(self, monkeypatch, recorder, change): + release = asyncio.Event() + started = asyncio.Event() + seen: list[str] = [] + + async def held(state, slot): + seen.append(slot.key) + if len(seen) == 2: + started.set() + if slot.key in {"a", "b"}: + await release.wait() + return f"New {slot.key}" + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", held) + slots = [_slot("a"), _slot("b"), _slot("c")] + state = _state(*slots) + run = asyncio.ensure_future(run_bulk_titles(state, slots, include_renamed=True)) + try: + await await_until(started, what="two titles generating") + change(state, slots[2]) + finally: + release.set() + await await_until(run, what="the bulk title run to finish") + return seen + + @pytest.mark.asyncio + async def test_a_session_that_starts_running_while_queued_is_skipped( + self, monkeypatch, recorder + ): + def start(_state, slot): + slot._turn_admission_reserved = True + + seen = await self._hold_first_two(monkeypatch, recorder, start) + + assert seen == ["a", "b"] + assert set(recorder.applied) == {"a", "b"} + + @pytest.mark.asyncio + async def test_a_session_whose_channel_turn_starts_while_queued_is_skipped( + self, monkeypatch, recorder + ): + def reply(_state, slot): + slot.linked_session_key = "slack:123.456" + slot._linked_session_busy = lambda key: True + + seen = await self._hold_first_two(monkeypatch, recorder, reply) + + assert seen == ["a", "b"] + assert set(recorder.applied) == {"a", "b"} + + @pytest.mark.asyncio + async def test_a_rename_while_queued_stands_even_with_renamed_included( + self, monkeypatch, recorder + ): + def rename(_state, slot): + slot._title_origin = "user" + slot._title_epoch += 1 + + seen = await self._hold_first_two(monkeypatch, recorder, rename) + + assert seen == ["a", "b"] + assert set(recorder.applied) == {"a", "b"} + + @pytest.mark.asyncio + async def test_a_session_still_under_construction_is_left_alone(self, recorder): + slot = _slot("s") + state = _state(slot) + state._slots_under_construction = {"s"} + + assert plan_bulk_titles(state, include_renamed=False).queued == [] + await run_bulk_titles(state, [slot], include_renamed=False) + assert recorder.applied == {} + + +class TestProgress: + @pytest.mark.asyncio + async def test_status_counts_each_outcome_once_the_run_ends(self, recorder): + good, bad, blank = _slot("good"), _slot("bad"), _slot("blank") + bad.title = "Deploy triage" + state = _state(good, bad, blank) + + def outcome(_state, target): + if target.key == "bad": + raise RuntimeError("model down") + if target.key == "blank": + target._title_epoch += 1 + + recorder.on_generate = outcome + async with TestClient(TestServer(_app(state))) as client: + started = await (await client.post("/api/chat/generate-titles", json={})).json() + await _drain(state) + status = await (await client.get("/api/chat/generate-titles")).json() + + assert status["run"] == { + "id": started["run_id"], + "total": 3, + "done": 3, + "retitled": 1, + "unchanged": 1, + "failed": 1, + "failed_sessions": [{"key": "bad", "title": "Deploy triage"}], + "finished": True, + } + + @pytest.mark.asyncio + async def test_status_reports_a_run_in_flight(self, monkeypatch, recorder): + release = asyncio.Event() + + async def held(state, slot): + await release.wait() + return f"New {slot.key}" + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", held) + state = _state(_slot("a"), _slot("b"), _slot("c")) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + mid = await (await client.get("/api/chat/generate-titles")).json() + release.set() + await _drain(state) + + assert mid["run"]["total"] == 3 + assert mid["run"]["done"] == 0 + assert mid["run"]["finished"] is False + + @pytest.mark.asyncio + async def test_a_second_run_waits_for_the_first(self, monkeypatch, recorder): + release = asyncio.Event() + + async def held(state, slot): + await release.wait() + return f"New {slot.key}" + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", held) + state = _state(_slot("a")) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + second = await client.post("/api/chat/generate-titles", json={}) + second_body = await second.json() + preview = await client.post("/api/chat/generate-titles", json={"dry_run": True}) + release.set() + await _drain(state) + + assert second.status == 409 + assert second_body["code"] == "bulk_titles_in_progress" + assert preview.status == 200 + + @pytest.mark.asyncio + async def test_status_is_empty_before_any_run(self, recorder): + async with TestClient(TestServer(_app(_state()))) as client: + status = await (await client.get("/api/chat/generate-titles")).json() + + assert status == {"ok": True, "run": None} + + @pytest.mark.asyncio + @pytest.mark.parametrize("caller", [None, "some-app"]) + async def test_only_a_dashboard_user_may_read_status(self, recorder, audit, caller): + async with TestClient(TestServer(_app(_state(), caller=caller))) as client: + resp = await client.get("/api/chat/generate-titles") + + assert resp.status == 403 + assert [(a["operation"], a["outcome"], a["source"]) for a in audit] == [ + ("chat.slots_generate_titles_status", "denied", "app_isolation") + ] + + @pytest.mark.asyncio + async def test_a_title_that_does_not_save_counts_as_failed(self, recorder, monkeypatch): + async def unsaved(state, slot, title, *, still_current=None): + return False + + monkeypatch.setattr(chat_title_bulk, "apply_regenerated_title", unsaved) + state = _state(_slot("s")) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + status = await (await client.get("/api/chat/generate-titles")).json() + + assert status["run"]["failed"] == 1 + assert status["run"]["retitled"] == 0 + + @pytest.mark.asyncio + async def test_a_title_that_does_not_save_is_rolled_back_and_re_shown(self, monkeypatch): + async def generate(state, slot): + return "Unsaved title" + + async def unsaved(state, slot, title, *, still_current=None): + slot.title = title + slot._title_origin = "auto" + slot._title_epoch += 1 + state.push_slot_title(slot.key, title) + return False + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", generate) + monkeypatch.setattr(chat_title_bulk, "apply_regenerated_title", unsaved) + slot = _slot("s") + slot.title = "Old title" + state = _state(slot) + pushed: list[tuple[str, str]] = [] + state.push_slot_title = lambda key, title, **kw: pushed.append((key, title)) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + + assert slot.title == "Old title" + assert slot._title_origin == "auto" + assert pushed == [("s", "Unsaved title"), ("s", "Old title")] + + @pytest.mark.asyncio + async def test_an_unsaved_title_overtaken_by_a_rename_is_left_alone(self, monkeypatch): + async def generate(state, slot): + return "Unsaved title" + + async def unsaved(state, slot, title, *, still_current=None): + slot.title = title + slot._title_epoch += 1 + slot.title = "Renamed" + slot._title_origin = "user" + slot._title_epoch += 1 + return False + + monkeypatch.setattr(chat_title_bulk, "_model_manual_title", generate) + monkeypatch.setattr(chat_title_bulk, "apply_regenerated_title", unsaved) + slot = _slot("s") + slot.title = "Old title" + state = _state(slot) + pushed: list[tuple[str, str]] = [] + state.push_slot_title = lambda key, title, **kw: pushed.append((key, title)) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + + assert slot.title == "Renamed" + assert slot._title_origin == "user" + assert pushed == [] + + @pytest.mark.asyncio + async def test_a_started_run_writes_one_audit_row(self, recorder, audit): + state = _state(_slot("a"), _slot("b")) + async with TestClient(TestServer(_app(state))) as client: + await client.post("/api/chat/generate-titles", json={"dry_run": True}) + await client.post("/api/chat/generate-titles", json={}) + await _drain(state) + + assert [(a["operation"], a["outcome"], a["source"]) for a in audit] == [ + ("chat.slots_generate_titles", "success", "dashboard") + ] + + +class TestRouteTable: + @pytest.mark.asyncio + async def test_the_bulk_routes_sit_outside_the_slot_namespace(self): + from kiro_crew.dashboard import chat, routes + + app = web.Application() + routes.register_all(app) + + async def handler(method: str, path: str): + return (await app.router.resolve(make_mocked_request(method, path, app=app))).handler + + status = api_chat_slots_generate_titles_status + assert await handler("GET", "/api/chat/generate-titles") is status + assert await handler("POST", "/api/chat/generate-titles") is api_chat_slots_generate_titles + assert await handler("GET", "/api/chat/slots/generate-titles") is chat.api_chat_slot_detail diff --git a/test/test_json_object_body_guard.py b/test/test_json_object_body_guard.py index c3e2cea3102..1fad3220819 100644 --- a/test/test_json_object_body_guard.py +++ b/test/test_json_object_body_guard.py @@ -373,6 +373,7 @@ async def json(self): ), "chat_api/slot_lifecycle.py::api_chat_slots_cleanup": ("", _BOUNDED_CONTROL_FIELDS), "chat_folder_cleanup.py::api_chat_folders_cleanup": ("", _BOUNDED_CONTROL_FIELDS), + "chat_title_bulk.py::api_chat_slots_generate_titles": ("", _BOUNDED_CONTROL_FIELDS), "chat_api/agent_switch.py::api_chat_slot_agent": ("", _BOUNDED_CONTROL_FIELDS), "chat_api/slot_model.py::api_chat_slot_model": ("", _BOUNDED_CONTROL_FIELDS), "chat_api/slot_model.py::api_chat_slots_model": ("", _BOUNDED_CONTROL_FIELDS), diff --git a/test/test_slot_running_readers.py b/test/test_slot_running_readers.py index 4edbc5c2913..d78798f5e2a 100644 --- a/test/test_slot_running_readers.py +++ b/test/test_slot_running_readers.py @@ -145,6 +145,7 @@ def test_running_and_turn_running_slot_readers_are_enumerated() -> None: # the create's awaits keeps it. ("chat_api/slot_create.py", "_retract_failed_newborn"), ("chat_api/slot_create.py", "api_chat_slot_create"), + ("chat_title_bulk.py", "_is_busy"), ("handlers/members_thread_turns.py", "_turn_busy"), ("handlers/members_thread_turns.py", "_turn_ends_within"), # The agent route's transaction; the in-turn /agent command shares it. diff --git a/website/src/api/client/chatSlotSettings.ts b/website/src/api/client/chatSlotSettings.ts index 6ce05f22d3d..cd9dcca530d 100644 --- a/website/src/api/client/chatSlotSettings.ts +++ b/website/src/api/client/chatSlotSettings.ts @@ -22,6 +22,18 @@ import type { * explain. Rationale in the CR description. */ export const SLASH_COMMANDS_TIMEOUT_MS = 15_000 +/** One bulk title run as `GET /api/chat/generate-titles` reports it. */ +export interface BulkTitleRun { + id: number + total: number + done: number + retitled: number + unchanged: number + failed: number + failed_sessions: { key: string; title: string }[] + finished: boolean +} + /** A session's turn time limit as `/api/chat/slots/{slot}/turn-timeout` reports it. */ export interface SlotTurnTimeout { secs: number | null @@ -114,6 +126,10 @@ export function createChatSlotSettingsEndpoints({ get, post, put, j, jfetch: fet * set has moved since the count. */ chatSlotsFollowDefault: (digest: string) => post('/api/chat/slots/follow-default', { digest }).then(j) as Promise<{ ok: boolean; changed: number }>, + chatSlotsGenerateTitles: ({ includeRenamed, dryRun }: { includeRenamed: boolean; dryRun: boolean }) => + post('/api/chat/generate-titles', { include_renamed: includeRenamed, dry_run: dryRun }).then(j) as Promise<{ ok: boolean; dry_run: boolean; run_id: number | null; queued: string[]; skipped_running: string[]; skipped_renamed: string[]; skipped_recent: string[]; skipped_empty: string[]; renamed: number }>, + chatSlotsGenerateTitlesStatus: () => + fetch('/api/chat/generate-titles').then(j) as Promise<{ ok: boolean; run: BulkTitleRun | null }>, chatSlotReasoningEffort: (slot: string, reasoning_effort: string) => post('/api/chat/slots/' + encodeURIComponent(slot) + '/reasoning-effort', { reasoning_effort }).then(j) as Promise<{ ok?: boolean; reasoning_effort?: string; model?: string; deferred?: boolean }>, /** Which Session Environment this slot's session runs on (`profile: null` = diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 88ae9b6f36a..71585ebf16c 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -13261,7 +13261,26 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "সাধারণ", "kind_section_mate": "সঙ্গীদের থেকে", - "filter_crew_description": "শুধু Ultra Mode চ্যাট দেখাও" + "filter_crew_description": "শুধু Ultra Mode চ্যাট দেখাও", + "regenerate_all_titles_menu": "সব শিরোনাম আবার তৈরি করো…", + "regenerate_all_titles": "সব শিরোনাম আবার তৈরি করো", + "bulk_titles_description": "প্রতিটি খোলা সেশনকে তার সাম্প্রতিক কথোপকথন থেকে নতুন শিরোনাম দেয়। যে সেশনে উত্তর চলছে সেগুলো বদলানো হয় না। তুমি যে শিরোনামগুলো নিজে বদলেছ সেগুলো রাখা হয়, যদি না নিচে সেগুলো অন্তর্ভুক্ত করো।", + "bulk_titles_to_retitle": "নতুন শিরোনাম পাবে এমন সেশন: {{n}}", + "bulk_titles_skipped_running": "চলছে, বদলানো হয়নি: {{n}}", + "bulk_titles_skipped_recent": "গত 5 মিনিটে নাম বদলানো হয়েছে, একটু পরে আবার চেষ্টা করো: {{n}}", + "bulk_titles_skipped_empty": "এখনও কোনো কথোপকথন নেই, বদলানো হয়নি: {{n}}", + "bulk_titles_include_renamed": "তোমার নাম বদলানো সেশনও অন্তর্ভুক্ত করো: {{n}}", + "bulk_titles_confirm": "শিরোনাম আবার তৈরি করো", + "bulk_titles_started_one": "{{count}}টি সেশনে শুরু হয়েছে। শিরোনাম তৈরি হলে সাইডবারে আপডেট হবে।", + "bulk_titles_started_other": "{{count}}টি সেশনে শুরু হয়েছে। প্রতিটি শিরোনাম তৈরি হলে সাইডবারে আপডেট হবে।", + "bulk_titles_progress": "সম্পন্ন: {{total}}টির মধ্যে {{done}}টি", + "bulk_titles_finished": "শেষ হয়েছে। নতুন শিরোনাম: {{n}}", + "bulk_titles_unchanged": "যেমন ছিল তেমন রাখা হয়েছে: {{n}}", + "bulk_titles_failed_count": "ব্যর্থ: {{n}}", + "bulk_titles_failed_untitled": "শিরোনামহীন সেশন", + "bulk_titles_failed": "শিরোনাম আবার তৈরি করা শুরু করা যায়নি।", + "bulk_titles_preview_failed": "নতুন শিরোনাম পাবে এমন সেশন গোনা যায়নি।", + "bulk_titles_status_failed": "শিরোনামের অগ্রগতি পড়া যায়নি।" }, "connectionsPage": { "action_failed": "অ্যাকশন ব্যর্থ: {{error}}", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 38ac44e4151..daa6a141f6d 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -13261,7 +13261,26 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Normal", "kind_section_mate": "Von Crewmates", - "filter_crew_description": "Nur Chats im Ultra Mode anzeigen" + "filter_crew_description": "Nur Chats im Ultra Mode anzeigen", + "regenerate_all_titles_menu": "Alle Titel neu erzeugen…", + "regenerate_all_titles": "Alle Titel neu erzeugen", + "bulk_titles_description": "Gibt jeder offenen Sitzung einen neuen Titel aus ihrem letzten Gesprächsverlauf. Sitzungen mit laufender Antwort bleiben unverändert. Titel, die du umbenannt hast, bleiben erhalten, außer du schließt sie unten ein.", + "bulk_titles_to_retitle": "Neu zu betitelnde Sitzungen: {{n}}", + "bulk_titles_skipped_running": "Laufend, unverändert: {{n}}", + "bulk_titles_skipped_recent": "In den letzten 5 Minuten umbenannt, versuch es gleich noch mal: {{n}}", + "bulk_titles_skipped_empty": "Noch kein Gespräch, unverändert: {{n}}", + "bulk_titles_include_renamed": "Von dir umbenannte Sitzungen einbeziehen: {{n}}", + "bulk_titles_confirm": "Titel neu erzeugen", + "bulk_titles_started_one": "Für {{count}} Sitzung gestartet. Ihr Titel erscheint in der Seitenleiste, sobald er fertig ist.", + "bulk_titles_started_other": "Für {{count}} Sitzungen gestartet. Jeder Titel erscheint in der Seitenleiste, sobald er fertig ist.", + "bulk_titles_progress": "Erledigt: {{done}} von {{total}}", + "bulk_titles_finished": "Fertig. Neu betitelt: {{n}}", + "bulk_titles_unchanged": "Unverändert gelassen: {{n}}", + "bulk_titles_failed_count": "Fehlgeschlagen: {{n}}", + "bulk_titles_failed_untitled": "Sitzung ohne Titel", + "bulk_titles_failed": "Titel konnten nicht neu erzeugt werden.", + "bulk_titles_preview_failed": "Die neu zu betitelnden Sitzungen konnten nicht gezählt werden.", + "bulk_titles_status_failed": "Der Fortschritt der Titel konnte nicht gelesen werden." }, "connectionsPage": { "action_failed": "Aktion fehlgeschlagen: {{error}}", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 16d4683ebe4..e04100bd15e 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -12738,6 +12738,25 @@ "broadcast_delivery_hint": "[Ñø ŕùññìñğ ẁøŕķ ìş ìñţèŕŕùþţèð: à ŕùññìñğ ţùŕñ ŕèàðş ìţ àƒţèŕ ìţş çùŕŕèñţ ţøøĺ çàĺĺ; àñ ìðĺè şèşşìøñ şţàŕţş à ñèẁ ţùŕñ. ····································]", "broadcast_retry": "[Ŕèţŕý {{n}} ƒàìĺèð ············]", "folder_renamed_to": "[Ƒøĺðèŕ ŕèñàɱèð ţø {{name}} ················]", + "regenerate_all_titles_menu": "[Ŕèğèñèŕàţè àĺĺ ţìţĺèş… ···············]", + "regenerate_all_titles": "[Ŕèğèñèŕàţè Àĺĺ Ţìţĺèş ···············]", + "bulk_titles_description": "[Ğìṽèş èàçĥ øþèñ şèşşìøñ à ñèẁ ţìţĺè ƒŕøɱ ìţş ŕèçèñţ çøñṽèŕşàţìøñ. Şèşşìøñş ẁìţĥ à ŕèþĺý ìñ þŕøğŕèşş àŕè ĺèƒţ àĺøñè. Ţìţĺèş ýøù ŕèñàɱèð àŕè ķèþţ ùñĺèşş ýøù ìñçĺùðè ţĥèɱ ƀèĺøẁ. ····················································]", + "bulk_titles_to_retitle": "[Şèşşìøñş ţø ŕèţìţĺè: {{n}} ···············]", + "bulk_titles_skipped_running": "[Ŕùññìñğ, ĺèƒţ àĺøñè: {{n}} ···············]", + "bulk_titles_skipped_recent": "[Ŕèţìţĺèð ìñ ţĥè ĺàşţ 5 ɱìñùţèş, ţŕý àğàìñ şĥøŕţĺý: {{n}} ··················]", + "bulk_titles_skipped_empty": "[Ñø çøñṽèŕşàţìøñ ýèţ, ĺèƒţ àĺøñè: {{n}} ·················]", + "bulk_titles_include_renamed": "[Ìñçĺùðè şèşşìøñş ýøù ŕèñàɱèð: {{n}} ·····················]", + "bulk_titles_confirm": "[Ŕèğèñèŕàţè ţìţĺèş ···············]", + "bulk_titles_started_one": "[Şţàŕţèð øñ {{count}} şèşşìøñ. Ìţş ţìţĺè ùþðàţèş ìñ ţĥè şìðèƀàŕ ẁĥèñ ìţ ìş ŕèàðý. ·····················]", + "bulk_titles_started_other": "[Şţàŕţèð øñ {{count}} şèşşìøñş. Èàçĥ ţìţĺè ùþðàţèş ìñ ţĥè şìðèƀàŕ ẁĥèñ ìţ ìş ŕèàðý. ······················]", + "bulk_titles_progress": "[Ðøñè: {{done}} øƒ {{total}} ···············]", + "bulk_titles_finished": "[Ƒìñìşĥèð. Ŕèţìţĺèð: {{n}} ··················]", + "bulk_titles_unchanged": "[Ĺèƒţ àş ţĥèý ẁèŕè: {{n}} ·················]", + "bulk_titles_failed_count": "[Ƒàìĺèð: {{n}} ············]", + "bulk_titles_failed_untitled": "[Ùñţìţĺèð şèşşìøñ ··············]", + "bulk_titles_failed": "[Çøùĺðñ'ţ şţàŕţ ŕèğèñèŕàţìñğ ţìţĺèş. ··················]", + "bulk_titles_preview_failed": "[Çøùĺðñ'ţ çøùñţ ţĥè şèşşìøñş ţø ŕèţìţĺè. ····················]", + "bulk_titles_status_failed": "[Çøùĺðñ'ţ ŕèàð ţĥè ţìţĺè þŕøğŕèşş. ·················]", "no_sessions_match_folders": "[Ñø şèşşìøñş ɱàţçĥ — øñĺý à ƒøĺðèŕ ñàɱè ðìð ·····················]", "history_search_failed": "[Şèàŕçĥ ƒàìĺèð. Şĥøẁìñğ ţìţĺè ɱàţçĥèş øñĺý. ·····················]", "answered_by": "[àñşẁèŕèð ƀý {{agent}} ···········]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index 212ed121eb9..dde1216d14a 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -7332,7 +7332,26 @@ "broadcast_open_unknown": "Not confirmed · open", "broadcast_delivery_hint": "No running work is interrupted: a running turn reads it after its current tool call; an idle session starts a new turn.", "broadcast_retry": "Retry {{n}} failed", - "folder_renamed_to": "Folder renamed to {{name}}" + "folder_renamed_to": "Folder renamed to {{name}}", + "regenerate_all_titles_menu": "Regenerate all titles…", + "regenerate_all_titles": "Regenerate All Titles", + "bulk_titles_description": "Gives each open session a new title from its recent conversation. Sessions with a reply in progress are left alone. Titles you renamed are kept unless you include them below.", + "bulk_titles_to_retitle": "Sessions to retitle: {{n}}", + "bulk_titles_skipped_running": "Running, left alone: {{n}}", + "bulk_titles_skipped_recent": "Retitled in the last 5 minutes, try again shortly: {{n}}", + "bulk_titles_skipped_empty": "No conversation yet, left alone: {{n}}", + "bulk_titles_include_renamed": "Include sessions you renamed: {{n}}", + "bulk_titles_confirm": "Regenerate titles", + "bulk_titles_started_one": "Started on {{count}} session. Its title updates in the sidebar when it is ready.", + "bulk_titles_started_other": "Started on {{count}} sessions. Each title updates in the sidebar when it is ready.", + "bulk_titles_progress": "Done: {{done}} of {{total}}", + "bulk_titles_finished": "Finished. Retitled: {{n}}", + "bulk_titles_unchanged": "Left as they were: {{n}}", + "bulk_titles_failed_count": "Failed: {{n}}", + "bulk_titles_failed_untitled": "Untitled session", + "bulk_titles_failed": "Couldn't start regenerating titles.", + "bulk_titles_preview_failed": "Couldn't count the sessions to retitle.", + "bulk_titles_status_failed": "Couldn't read the title progress." }, "contextBreakdown": { "axis_turn_n": "Turn {{n}}", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index bb818faa646..a4d1b958275 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -13486,7 +13486,27 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Normal", "kind_section_mate": "De compañeros", - "filter_crew_description": "Mostrar solo chats de Ultra Mode" + "filter_crew_description": "Mostrar solo chats de Ultra Mode", + "regenerate_all_titles_menu": "Regenerar todos los títulos…", + "regenerate_all_titles": "Regenerar todos los títulos", + "bulk_titles_description": "Da a cada sesión abierta un título nuevo a partir de su conversación reciente. Las sesiones con una respuesta en curso no se tocan. Los títulos que cambiaste se conservan, salvo que los incluyas abajo.", + "bulk_titles_to_retitle": "Sesiones por renombrar: {{n}}", + "bulk_titles_skipped_running": "En ejecución, sin cambios: {{n}}", + "bulk_titles_skipped_recent": "Renombradas en los últimos 5 minutos, vuelve a intentarlo en breve: {{n}}", + "bulk_titles_skipped_empty": "Aún sin conversación, sin cambios: {{n}}", + "bulk_titles_include_renamed": "Incluir sesiones que renombraste: {{n}}", + "bulk_titles_confirm": "Regenerar títulos", + "bulk_titles_started_one": "Iniciado en {{count}} sesión. Su título se actualiza en la barra lateral cuando está listo.", + "bulk_titles_started_many": "Iniciado en {{count}} de sesiones. Cada título se actualiza en la barra lateral cuando está listo.", + "bulk_titles_started_other": "Iniciado en {{count}} sesiones. Cada título se actualiza en la barra lateral cuando está listo.", + "bulk_titles_progress": "Hecho: {{done}} de {{total}}", + "bulk_titles_finished": "Terminado. Con título nuevo: {{n}}", + "bulk_titles_unchanged": "Sin cambios: {{n}}", + "bulk_titles_failed_count": "Fallidas: {{n}}", + "bulk_titles_failed_untitled": "Sesión sin título", + "bulk_titles_failed": "No se pudo empezar a regenerar los títulos.", + "bulk_titles_preview_failed": "No se pudieron contar las sesiones por renombrar.", + "bulk_titles_status_failed": "No se pudo leer el progreso de los títulos." }, "connectionsPage": { "action_failed": "La acción falló: {{error}}", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 026fc178ef0..ec6f435e594 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -13486,7 +13486,27 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Normal", "kind_section_mate": "Des coéquipiers", - "filter_crew_description": "Afficher uniquement les discussions Ultra Mode" + "filter_crew_description": "Afficher uniquement les discussions Ultra Mode", + "regenerate_all_titles_menu": "Régénérer tous les titres…", + "regenerate_all_titles": "Régénérer tous les titres", + "bulk_titles_description": "Donne à chaque session ouverte un nouveau titre tiré de sa conversation récente. Les sessions dont une réponse est en cours ne sont pas modifiées. Les titres que tu as renommés sont conservés, sauf si tu les inclus ci-dessous.", + "bulk_titles_to_retitle": "Sessions à renommer : {{n}}", + "bulk_titles_skipped_running": "En cours, non modifiées : {{n}}", + "bulk_titles_skipped_recent": "Renommées dans les 5 dernières minutes, réessaie bientôt : {{n}}", + "bulk_titles_skipped_empty": "Pas encore de conversation, non modifiées : {{n}}", + "bulk_titles_include_renamed": "Inclure les sessions que tu as renommées : {{n}}", + "bulk_titles_confirm": "Régénérer les titres", + "bulk_titles_started_one": "Lancé sur {{count}} session. Son titre s'actualise dans la barre latérale dès qu'il est prêt.", + "bulk_titles_started_many": "Lancé sur {{count}} de sessions. Chaque titre s'actualise dans la barre latérale dès qu'il est prêt.", + "bulk_titles_started_other": "Lancé sur {{count}} sessions. Chaque titre s'actualise dans la barre latérale dès qu'il est prêt.", + "bulk_titles_progress": "Fait : {{done}} sur {{total}}", + "bulk_titles_finished": "Terminé. Renommées : {{n}}", + "bulk_titles_unchanged": "Laissées telles quelles : {{n}}", + "bulk_titles_failed_count": "Échecs : {{n}}", + "bulk_titles_failed_untitled": "Session sans titre", + "bulk_titles_failed": "Impossible de lancer la régénération des titres.", + "bulk_titles_preview_failed": "Impossible de compter les sessions à renommer.", + "bulk_titles_status_failed": "Impossible de lire la progression des titres." }, "connectionsPage": { "action_failed": "Échec de l’action : {{error}}", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index fd1b030cd8f..a1683cb7059 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -13261,7 +13261,26 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "सामान्य", "kind_section_mate": "साथियों से", - "filter_crew_description": "केवल Ultra Mode चैट दिखाओ" + "filter_crew_description": "केवल Ultra Mode चैट दिखाओ", + "regenerate_all_titles_menu": "सभी शीर्षक फिर से बनाओ…", + "regenerate_all_titles": "सभी शीर्षक फिर से बनाओ", + "bulk_titles_description": "हर खुले सत्र को उसकी हाल की बातचीत से नया शीर्षक देता है। जिन सत्रों में जवाब चल रहा है, उन्हें नहीं छुआ जाता। तुम्हारे बदले हुए शीर्षक बने रहते हैं, जब तक तुम उन्हें नीचे शामिल न करो।", + "bulk_titles_to_retitle": "नया शीर्षक पाने वाले सत्र: {{n}}", + "bulk_titles_skipped_running": "चल रहे, बिना बदलाव: {{n}}", + "bulk_titles_skipped_recent": "पिछले 5 मिनट में नाम बदला गया, थोड़ी देर में फिर से कोशिश करो: {{n}}", + "bulk_titles_skipped_empty": "अभी कोई बातचीत नहीं, बिना बदलाव: {{n}}", + "bulk_titles_include_renamed": "अपने नाम बदले सत्र भी शामिल करो: {{n}}", + "bulk_titles_confirm": "शीर्षक फिर से बनाओ", + "bulk_titles_started_one": "{{count}} सत्र पर शुरू किया। शीर्षक तैयार होते ही साइडबार में अपडेट हो जाएगा।", + "bulk_titles_started_other": "{{count}} सत्रों पर शुरू किया। हर शीर्षक तैयार होते ही साइडबार में अपडेट हो जाएगा।", + "bulk_titles_progress": "पूरा: {{total}} में से {{done}}", + "bulk_titles_finished": "समाप्त। नया शीर्षक: {{n}}", + "bulk_titles_unchanged": "जैसे थे वैसे छोड़े: {{n}}", + "bulk_titles_failed_count": "विफल: {{n}}", + "bulk_titles_failed_untitled": "बिना शीर्षक का सत्र", + "bulk_titles_failed": "शीर्षक फिर से बनाना शुरू नहीं हो सका।", + "bulk_titles_preview_failed": "नया शीर्षक पाने वाले सत्र गिने नहीं जा सके।", + "bulk_titles_status_failed": "शीर्षक की प्रगति पढ़ी नहीं जा सकी।" }, "connectionsPage": { "action_failed": "कार्रवाई विफल: {{error}}", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index afa7b6f6a68..9fc70720430 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -13486,7 +13486,27 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Normale", "kind_section_mate": "Dai compagni", - "filter_crew_description": "Mostra solo le chat Ultra Mode" + "filter_crew_description": "Mostra solo le chat Ultra Mode", + "regenerate_all_titles_menu": "Rigenera tutti i titoli…", + "regenerate_all_titles": "Rigenera tutti i titoli", + "bulk_titles_description": "Assegna a ogni sessione aperta un nuovo titolo in base alla conversazione recente. Le sessioni con una risposta in corso restano invariate. I titoli che hai rinominato restano invariati, a meno che tu non li includa qui sotto.", + "bulk_titles_to_retitle": "Sessioni da rinominare: {{n}}", + "bulk_titles_skipped_running": "In esecuzione, invariate: {{n}}", + "bulk_titles_skipped_recent": "Rinominate negli ultimi 5 minuti, riprova tra poco: {{n}}", + "bulk_titles_skipped_empty": "Ancora nessuna conversazione, invariate: {{n}}", + "bulk_titles_include_renamed": "Includi le sessioni che hai rinominato: {{n}}", + "bulk_titles_confirm": "Rigenera i titoli", + "bulk_titles_started_one": "Avviato su {{count}} sessione. Il titolo si aggiorna nella barra laterale appena è pronto.", + "bulk_titles_started_many": "Avviato su {{count}} di sessioni. Ogni titolo si aggiorna nella barra laterale appena è pronto.", + "bulk_titles_started_other": "Avviato su {{count}} sessioni. Ogni titolo si aggiorna nella barra laterale appena è pronto.", + "bulk_titles_progress": "Fatto: {{done}} di {{total}}", + "bulk_titles_finished": "Finito. Nuovo titolo: {{n}}", + "bulk_titles_unchanged": "Lasciate com'erano: {{n}}", + "bulk_titles_failed_count": "Non riuscite: {{n}}", + "bulk_titles_failed_untitled": "Sessione senza titolo", + "bulk_titles_failed": "Impossibile avviare la rigenerazione dei titoli.", + "bulk_titles_preview_failed": "Impossibile contare le sessioni da rinominare.", + "bulk_titles_status_failed": "Impossibile leggere l'avanzamento dei titoli." }, "connectionsPage": { "action_failed": "Azione non riuscita: {{error}}", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 08cb26e090f..c16fc13d6b9 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -13036,7 +13036,25 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "通常", "kind_section_mate": "クルーメイトから", - "filter_crew_description": "Ultra Mode のチャットだけを表示" + "filter_crew_description": "Ultra Mode のチャットだけを表示", + "regenerate_all_titles_menu": "すべてのタイトルを再生成…", + "regenerate_all_titles": "すべてのタイトルを再生成", + "bulk_titles_description": "開いている各セッションに、最近の会話から新しいタイトルを付けます。応答中のセッションはそのままにします。名前を変更したタイトルは、下で含めない限りそのままです。", + "bulk_titles_to_retitle": "タイトルを付け直すセッション: {{n}}", + "bulk_titles_skipped_running": "実行中のためそのまま: {{n}}", + "bulk_titles_skipped_recent": "直近 5 分以内にタイトルを変更済み。少し待ってから再試行してください: {{n}}", + "bulk_titles_skipped_empty": "まだ会話がないためそのまま: {{n}}", + "bulk_titles_include_renamed": "自分で名前を変えたセッションも含める: {{n}}", + "bulk_titles_confirm": "タイトルを再生成", + "bulk_titles_started_other": "{{count}} 件のセッションで開始しました。各タイトルは準備ができるとサイドバーで更新されます。", + "bulk_titles_progress": "完了: {{done}} / {{total}}", + "bulk_titles_finished": "完了しました。新しいタイトル: {{n}}", + "bulk_titles_unchanged": "変更なし: {{n}}", + "bulk_titles_failed_count": "失敗: {{n}}", + "bulk_titles_failed_untitled": "無題のセッション", + "bulk_titles_failed": "タイトルの再生成を開始できませんでした。", + "bulk_titles_preview_failed": "タイトルを付け直すセッションを数えられませんでした。", + "bulk_titles_status_failed": "タイトルの進行状況を読み込めませんでした。" }, "connectionsPage": { "action_failed": "操作に失敗しました: {{error}}", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 3dedb63e5c2..160969d8a35 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -13036,7 +13036,25 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "일반", "kind_section_mate": "크루메이트에서", - "filter_crew_description": "Ultra Mode 채팅만 보기" + "filter_crew_description": "Ultra Mode 채팅만 보기", + "regenerate_all_titles_menu": "모든 제목 다시 생성…", + "regenerate_all_titles": "모든 제목 다시 생성", + "bulk_titles_description": "열려 있는 각 세션에 최근 대화를 바탕으로 새 제목을 붙입니다. 응답이 진행 중인 세션은 그대로 둡니다. 직접 바꾼 제목은 아래에서 포함하지 않으면 그대로 유지됩니다.", + "bulk_titles_to_retitle": "제목을 다시 붙일 세션: {{n}}", + "bulk_titles_skipped_running": "실행 중이라 그대로 둠: {{n}}", + "bulk_titles_skipped_recent": "최근 5분 안에 제목이 바뀜, 잠시 후 다시 시도하세요: {{n}}", + "bulk_titles_skipped_empty": "아직 대화가 없어 그대로 둠: {{n}}", + "bulk_titles_include_renamed": "직접 이름을 바꾼 세션 포함: {{n}}", + "bulk_titles_confirm": "제목 다시 생성", + "bulk_titles_started_other": "세션 {{count}}개에서 시작했습니다. 각 제목은 준비되면 사이드바에서 바뀝니다.", + "bulk_titles_progress": "완료: {{total}}개 중 {{done}}개", + "bulk_titles_finished": "끝났습니다. 새 제목: {{n}}", + "bulk_titles_unchanged": "그대로 둠: {{n}}", + "bulk_titles_failed_count": "실패: {{n}}", + "bulk_titles_failed_untitled": "제목 없는 세션", + "bulk_titles_failed": "제목 다시 생성을 시작하지 못했습니다.", + "bulk_titles_preview_failed": "제목을 다시 붙일 세션 수를 세지 못했습니다.", + "bulk_titles_status_failed": "제목 진행 상황을 읽을 수 없습니다." }, "connectionsPage": { "action_failed": "작업에 실패했습니다: {{error}}", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index a18c0e04f59..88faf16f402 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -13486,7 +13486,27 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Normal", "kind_section_mate": "De colegas", - "filter_crew_description": "Mostrar só chats do Ultra Mode" + "filter_crew_description": "Mostrar só chats do Ultra Mode", + "regenerate_all_titles_menu": "Regenerar todos os títulos…", + "regenerate_all_titles": "Regenerar todos os títulos", + "bulk_titles_description": "Dá a cada sessão aberta um novo título a partir da conversa recente. Sessões com uma resposta em andamento não são alteradas. Os títulos que você renomeou são mantidos, a menos que você os inclua abaixo.", + "bulk_titles_to_retitle": "Sessões a renomear: {{n}}", + "bulk_titles_skipped_running": "Em execução, sem alteração: {{n}}", + "bulk_titles_skipped_recent": "Renomeadas nos últimos 5 minutos, tente de novo daqui a pouco: {{n}}", + "bulk_titles_skipped_empty": "Ainda sem conversa, sem alteração: {{n}}", + "bulk_titles_include_renamed": "Incluir sessões que você renomeou: {{n}}", + "bulk_titles_confirm": "Regenerar títulos", + "bulk_titles_started_one": "Iniciado em {{count}} sessão. O título é atualizado na barra lateral quando fica pronto.", + "bulk_titles_started_many": "Iniciado em {{count}} de sessões. Cada título é atualizado na barra lateral quando fica pronto.", + "bulk_titles_started_other": "Iniciado em {{count}} sessões. Cada título é atualizado na barra lateral quando fica pronto.", + "bulk_titles_progress": "Concluído: {{done}} de {{total}}", + "bulk_titles_finished": "Terminado. Com novo título: {{n}}", + "bulk_titles_unchanged": "Mantidas como estavam: {{n}}", + "bulk_titles_failed_count": "Com falha: {{n}}", + "bulk_titles_failed_untitled": "Sessão sem título", + "bulk_titles_failed": "Não foi possível começar a regenerar os títulos.", + "bulk_titles_preview_failed": "Não foi possível contar as sessões a renomear.", + "bulk_titles_status_failed": "Não foi possível ler o progresso dos títulos." }, "connectionsPage": { "action_failed": "Falha na ação: {{error}}", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index e9ac063d856..2839bf4a8b8 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -13711,7 +13711,28 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "Обычные", "kind_section_mate": "От напарников", - "filter_crew_description": "Показывать только чаты Ultra Mode" + "filter_crew_description": "Показывать только чаты Ultra Mode", + "regenerate_all_titles_menu": "Пересоздать все заголовки…", + "regenerate_all_titles": "Пересоздать все заголовки", + "bulk_titles_description": "Даёт каждой открытой сессии новый заголовок по её недавнему разговору. Сессии, в которых идёт ответ, не меняются. Переименованные тобой заголовки сохраняются, если ты не включишь их ниже.", + "bulk_titles_to_retitle": "Сессий к переименованию: {{n}}", + "bulk_titles_skipped_running": "Выполняются, без изменений: {{n}}", + "bulk_titles_skipped_recent": "Переименованы за последние 5 минут, попробуй чуть позже: {{n}}", + "bulk_titles_skipped_empty": "Разговора ещё нет, без изменений: {{n}}", + "bulk_titles_include_renamed": "Включить переименованные тобой сессии: {{n}}", + "bulk_titles_confirm": "Пересоздать заголовки", + "bulk_titles_started_one": "Запущено для {{count}} сессии. Заголовок обновится на боковой панели, когда будет готов.", + "bulk_titles_started_few": "Запущено для {{count}} сессий. Каждый заголовок обновится на боковой панели, когда будет готов.", + "bulk_titles_started_many": "Запущено для {{count}} сессий. Каждый заголовок обновится на боковой панели, когда будет готов.", + "bulk_titles_started_other": "Запущено для {{count}} сессии. Каждый заголовок обновится на боковой панели, когда будет готов.", + "bulk_titles_progress": "Готово: {{done}} из {{total}}", + "bulk_titles_finished": "Завершено. Новый заголовок: {{n}}", + "bulk_titles_unchanged": "Оставлены как были: {{n}}", + "bulk_titles_failed_count": "Ошибки: {{n}}", + "bulk_titles_failed_untitled": "Сессия без названия", + "bulk_titles_failed": "Не удалось начать пересоздание заголовков.", + "bulk_titles_preview_failed": "Не удалось подсчитать сессии к переименованию.", + "bulk_titles_status_failed": "Не удалось прочитать ход пересоздания заголовков." }, "connectionsPage": { "action_failed": "Не удалось выполнить действие: {{error}}", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 67ec4bab11c..df1b64d41a0 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -13036,7 +13036,25 @@ "kind_section_crew": "Ultra Mode", "kind_section_normal": "普通", "kind_section_mate": "来自队友", - "filter_crew_description": "只显示 Ultra Mode 对话" + "filter_crew_description": "只显示 Ultra Mode 对话", + "regenerate_all_titles_menu": "重新生成所有标题…", + "regenerate_all_titles": "重新生成所有标题", + "bulk_titles_description": "根据最近的对话为每个打开的会话重新生成标题。正在回复的会话保持不变。你重命名过的标题会保留,除非在下方选择包含它们。", + "bulk_titles_to_retitle": "要重新命名的会话:{{n}}", + "bulk_titles_skipped_running": "正在运行,保持不变:{{n}}", + "bulk_titles_skipped_recent": "过去 5 分钟内已重命名,稍后再试:{{n}}", + "bulk_titles_skipped_empty": "还没有对话,保持不变:{{n}}", + "bulk_titles_include_renamed": "包括你手动重命名的会话:{{n}}", + "bulk_titles_confirm": "重新生成标题", + "bulk_titles_started_other": "已在 {{count}} 个会话上开始。每个标题准备好后会在侧边栏中更新。", + "bulk_titles_progress": "已完成:{{done}} / {{total}}", + "bulk_titles_finished": "已结束。已重新命名:{{n}}", + "bulk_titles_unchanged": "保持原样:{{n}}", + "bulk_titles_failed_count": "失败:{{n}}", + "bulk_titles_failed_untitled": "未命名会话", + "bulk_titles_failed": "无法开始重新生成标题。", + "bulk_titles_preview_failed": "无法统计要重新命名的会话。", + "bulk_titles_status_failed": "无法读取标题进度。" }, "connectionsPage": { "action_failed": "操作失败:{{error}}", diff --git a/website/src/i18n/pluralKeys.json b/website/src/i18n/pluralKeys.json index 104dbe06667..7f2fa794e91 100644 --- a/website/src/i18n/pluralKeys.json +++ b/website/src/i18n/pluralKeys.json @@ -327,5 +327,6 @@ "pages.settings.postureDisclosure.count_credential_families", "pages.settings.postureDisclosure.count_exfil_heuristics", "pages.settings.postureDisclosure.count_audit_surfaces", - "pages.settings.postureDisclosure.count_token_auth" + "pages.settings.postureDisclosure.count_token_auth", + "pages.chatSidebar.bulk_titles_started" ] diff --git a/website/src/pages/ChatSidebar.tsx b/website/src/pages/ChatSidebar.tsx index 72e659a3374..b6213e4d7e2 100644 --- a/website/src/pages/ChatSidebar.tsx +++ b/website/src/pages/ChatSidebar.tsx @@ -88,6 +88,7 @@ import { renderHistoryPane } from './chat-sidebar/HistoryPane' import { renderFolderModal } from './chat-sidebar/folderModal' import { renderSessionCleanupPanel, useSessionCleanup } from './chat-sidebar/sessionCleanup' import { renderBulkModelPanel, useBulkModelSwitch } from './chat-sidebar/bulkModelSwitch' +import { renderBulkTitlesPanel, useBulkTitles } from './chat-sidebar/bulkTitles' import BroadcastPanel from './chat-sidebar/BroadcastPanel' import { makeKindOf, type KindSections } from './chat-sidebar/kindSections' import type { Slot } from './chat-sidebar/types' @@ -312,6 +313,7 @@ function ChatSidebar({ const sessionCleanup = useSessionCleanup({ cleanupOpen, setCleanupOpen, activeSlot, dispatch, queryClient }) const bulkModel = useBulkModelSwitch({ localSlots }) + const bulkTitles = useBulkTitles() const { pinned, pinnedOrder, pinnedRank, reorderPinned, pinnedOrderError, setPinnedOrderError, @@ -832,7 +834,7 @@ function ChatSidebar({ const scope: SidebarScope = { activeDrag, activeFilters, activeModelKeys, activeModelRows, activeSlot, activeTagIds, activeTagNames, addColumnAfterMutation, allHiddenFolders, armHold, attachStripEdges, autoTitleError, availableLanes, boardError, boardFolderCollapsed, boardLaneActive, boardPeerCount, - broadcastOpen, bulkModel, changeRecentUnit, chatDropTarget, citedCreatorExists, cleanupOpen, + broadcastOpen, bulkModel, bulkTitles, changeRecentUnit, chatDropTarget, citedCreatorExists, cleanupOpen, clearClosedSessionsWithConfirm, clearHiddenFilters, clearModelFilter, clearTagFilter, closeColumnPopover, closeRefused, collapsedCrews, collapsedHistoryGroups, collapsedSpaces, collapsible, columnEditId, columnMatches, columnPopoverImeLatch, columnPopoverRef, columnsError, columnsFailed, commitRecentAmount, @@ -952,6 +954,7 @@ function ChatSidebar({ * running session left unskipped, so the red caveat renders only once * the skip box is cleared with some session running. */} {bulkModel.bulkModelOpen && renderBulkModelPanel(scope)} + {bulkTitles.bulkTitlesOpen && renderBulkTitlesPanel(scope)} {/* Broadcast dialog — one message steered into every session the pick * reaches: a running turn takes it after its current tool call, an idle diff --git a/website/src/pages/chat-sidebar/SidebarHeader.tsx b/website/src/pages/chat-sidebar/SidebarHeader.tsx index 5d54759bcbd..a1996173bf1 100644 --- a/website/src/pages/chat-sidebar/SidebarHeader.tsx +++ b/website/src/pages/chat-sidebar/SidebarHeader.tsx @@ -1,7 +1,7 @@ /** The sidebar header: the Sessions title, the list menu (dashboards, board * toggle, cleanup, model switch, tags) and the split New button with its create * menu (folders, private chats, import, chat in a folder or on a crew). */ -import { Plus, Monitor, FolderPlus, FolderX, MessageSquarePlus, Folder, ChevronRight, ChevronDown, BrushCleaning, MoreVertical, Tag as TagIcon, Columns3, Loader2, Cpu, Server, Megaphone } from 'lucide-react' +import { Plus, Monitor, FolderPlus, FolderX, MessageSquarePlus, Folder, ChevronRight, ChevronDown, BrushCleaning, MoreVertical, Tag as TagIcon, Columns3, Loader2, Cpu, Server, Megaphone, Sparkles } from 'lucide-react' import ErrorNotice, { ErrorNoticeMenuItem } from '../../components/ErrorNotice' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from '../../components/ui/dropdown-menu' import { LIST_HEADER_CLS, LIST_TITLE_CLS } from '../../components/listShell' @@ -28,6 +28,7 @@ export function renderSidebarHeader(shell: SidebarScope): React.ReactNode { tagColumnsEnabled, warmCrews } = shell const { setCleanupError, setCleanupExpanded } = shell.sessionCleanup const { setBulkModel, setBulkModelError, setBulkModelOpen, setBulkSkipRunning } = shell.bulkModel + const { openBulkTitles } = shell.bulkTitles // Narrow-sidebar header responsiveness: below ~256px the full "New chat" // label no longer fits next to the label + kebab, so collapse the create // button to icon-only; below ~200px also drop the "Sessions" label. @@ -107,6 +108,10 @@ export function renderSidebarHeader(shell: SidebarScope): React.ReactNode { {i18nT('pages.chatSidebar.switch_all_to_model')} + + + {i18nT('pages.chatSidebar.regenerate_all_titles_menu')} + setBroadcastOpen(true)}> {i18nT('pages.chatSidebar.broadcast_menu')} diff --git a/website/src/pages/chat-sidebar/bulkTitles.tsx b/website/src/pages/chat-sidebar/bulkTitles.tsx new file mode 100644 index 00000000000..026bf11835b --- /dev/null +++ b/website/src/pages/chat-sidebar/bulkTitles.tsx @@ -0,0 +1,159 @@ +import { useEffect, useId, useState } from 'react' +import { Sparkles } from 'lucide-react' +import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' +import ErrorNotice from '../../components/ErrorNotice' +import { api } from '../../api/client' +import { Btn } from '../../components/ui' +import { i18nT } from '../../i18n/t' +import { switchSlot } from '../../store/chatSlice' +import type { SidebarScope } from './scope' + +const BULK_TITLES_POLL_MS = 1_000 + +export function useBulkTitles() { + const [bulkTitlesOpen, setBulkTitlesOpen] = useState(false) + const [includeRenamed, setIncludeRenamed] = useState(false) + const [started, setStarted] = useState(null) + const [runId, setRunId] = useState(null) + const [appliedAt, setAppliedAt] = useState(0) + const includeRenamedLabelId = useId() + const status = useQuery({ + queryKey: ['bulk-titles-status'], + queryFn: () => api.chatSlotsGenerateTitlesStatus(), + enabled: bulkTitlesOpen, + staleTime: 0, + gcTime: 0, + retry: false, + refetchInterval: q => { + const latest = q.state.data?.run + const waiting = runId !== null && latest?.id !== runId && q.state.dataUpdatedAt < appliedAt + return q.state.status === 'error' || waiting || (latest != null && !latest.finished) ? BULK_TITLES_POLL_MS : false + }, + }) + const statusRun = status.data?.run ?? null + useEffect(() => { + if (!statusRun) return + if (runId === null ? !statusRun.finished : statusRun.id > runId) setRunId(statusRun.id) + }, [runId, statusRun]) + const run = statusRun && (runId === null ? !statusRun.finished : statusRun.id === runId) ? statusRun : null + const preview = useQuery({ + queryKey: ['bulk-titles-preview', includeRenamed], + queryFn: () => api.chatSlotsGenerateTitles({ includeRenamed, dryRun: true }), + enabled: bulkTitlesOpen && started === null && run === null && status.isFetched, + staleTime: 0, + gcTime: 0, + retry: false, + placeholderData: keepPreviousData, + }) + const apply = useMutation({ + mutationFn: () => api.chatSlotsGenerateTitles({ includeRenamed, dryRun: false }), + onSuccess: (res) => { + if (res.run_id == null) { + void preview.refetch() + return + } + setStarted(res.queued.length) + setRunId(res.run_id ?? null) + setAppliedAt(Date.now()) + void status.refetch() + }, + onError: () => { + void status.refetch() + }, + }) + const openBulkTitles = () => { + setIncludeRenamed(false) + setStarted(null) + setRunId(null) + setAppliedAt(0) + apply.reset() + setBulkTitlesOpen(true) + } + const closeBulkTitles = () => setBulkTitlesOpen(false) + return { bulkTitlesOpen, openBulkTitles, closeBulkTitles, includeRenamed, setIncludeRenamed, includeRenamedLabelId, preview, apply, started, run, statusFailed: status.isError } +} + +export function renderBulkTitlesPanel(shell: SidebarScope): React.ReactNode { + const { apply, closeBulkTitles, includeRenamed, includeRenamedLabelId, preview, run, setIncludeRenamed, started, statusFailed } = shell.bulkTitles + const plan = preview.data + const queued = plan?.queued.length ?? 0 + const inProgress = started !== null || run !== null + return ( +
+
{i18nT('pages.chatSidebar.regenerate_all_titles')}
+
{i18nT('pages.chatSidebar.bulk_titles_description')}
+ + {inProgress ? ( +
+
{i18nT('pages.chatSidebar.bulk_titles_started', { count: run?.total ?? started ?? 0 })}
+ {run && !run.finished && ( +
{i18nT('pages.chatSidebar.bulk_titles_progress', { done: run.done, total: run.total })}
+ )} + {run?.finished && ( +
+
{i18nT('pages.chatSidebar.bulk_titles_finished', { n: run.retitled })}
+ {run.unchanged > 0 &&
{i18nT('pages.chatSidebar.bulk_titles_unchanged', { n: run.unchanged })}
} +
+ )} +
+ ) : null} + {inProgress && ( + 0 ? i18nT('pages.chatSidebar.bulk_titles_failed_count', { n: run.failed }) : ''} + askAgent + actionPlacement="below" + className="mb-2" + testId="bulk-titles-failed-count" + footer={run?.failed_sessions.length ? ( +
    + {run.failed_sessions.map(s => ( +
  • + +
  • + ))} +
+ ) : null} + /> + )} + {inProgress ? null : ( + <> + + {plan && ( +
+
{i18nT('pages.chatSidebar.bulk_titles_to_retitle', { n: queued })}
+ {plan.skipped_running.length > 0 &&
{i18nT('pages.chatSidebar.bulk_titles_skipped_running', { n: plan.skipped_running.length })}
} + {plan.skipped_recent.length > 0 &&
{i18nT('pages.chatSidebar.bulk_titles_skipped_recent', { n: plan.skipped_recent.length })}
} + {plan.skipped_empty.length > 0 &&
{i18nT('pages.chatSidebar.bulk_titles_skipped_empty', { n: plan.skipped_empty.length })}
} +
+ )} + {plan && plan.renamed > 0 && ( + + )} + + + )} +
+ {inProgress ? ( + {i18nT('pages.chatSidebar.close')} + ) : ( + <> + {i18nT('pages.chatSidebar.cancel')} + apply.mutate()}>{i18nT('pages.chatSidebar.bulk_titles_confirm')} + + )} +
+
+ ) +} diff --git a/website/src/pages/chat-sidebar/scope.ts b/website/src/pages/chat-sidebar/scope.ts index 4ed2cfbe11a..90f58051816 100644 --- a/website/src/pages/chat-sidebar/scope.ts +++ b/website/src/pages/chat-sidebar/scope.ts @@ -17,6 +17,7 @@ import type { useCollapsedCrewSpaces, useCollapsedCrews, useCrewEnable } from '. import type { SessionRow } from './SessionRow' import type { useBoardColumnMutations, useBoardColumns, useBoardFolderCollapse, useColumnMatches, useColumnPopover } from './board' import type { useBulkModelSwitch } from './bulkModelSwitch' +import type { useBulkTitles } from './bulkTitles' import type { useConductorLane } from './conductor' import type { useFolderChatCreate, useSessionCreate } from './create' import type { useFolderDropOps, useNativeSessionDrag, useSidebarDragHandlers, useSidebarMoveUndo } from './dnd/useSidebarDrag' @@ -336,6 +337,9 @@ export interface SidebarScope { // From `useBulkModelSwitch` bulkModel: ReturnType + // From `useBulkTitles` + bulkTitles: ReturnType + // From `usePinnedSessionOrder` pinned: ReturnType['pinned'] pinnedOrderError: ReturnType['pinnedOrderError'] diff --git a/website/src/store/switchSlotCallsiteClassification.test.ts b/website/src/store/switchSlotCallsiteClassification.test.ts index d3e9145bb9b..34cc5b0755e 100644 --- a/website/src/store/switchSlotCallsiteClassification.test.ts +++ b/website/src/store/switchSlotCallsiteClassification.test.ts @@ -55,6 +55,7 @@ const PINNED: Record = { 'src/pages/ChatSidebar.tsx': { gesture: 1, announced: 1, keepTarget: 0, plain: 0, reason: 'the broadcast panel\'s "Not confirmed · open" opens a LISTED row the way that row\'s own click does, so it announces' }, 'src/pages/chat-sidebar/CrewGroups.tsx': { gesture: 1, announced: 1, keepTarget: 0, plain: 0, reason: 'the crew header\'s attention button opens a LISTED row the way that row\'s own click does, so it announces' }, 'src/pages/chat-sidebar/SessionRow.tsx': { gesture: 3, announced: 3, keepTarget: 0, plain: 0, reason: 'every site is a gesture on a LISTED session row, so all three announce: the row\'s keyboard activation, its click, and the source chip\'s onActivateSlot' }, + 'src/pages/chat-sidebar/bulkTitles.tsx': { gesture: 1, announced: 1, keepTarget: 0, plain: 0, reason: 'a failed-session link in the bulk title panel opens a LISTED session, so it announces and is a gesture' }, 'src/pages/chat-sidebar/folderBlocks.tsx': { gesture: 2, announced: 2, keepTarget: 0, plain: 0, reason: 'every site is a gesture on a LISTED folder, so both announce: the two ways to reach a folder\'s conductor -- the item in both folder menus and the chat button in the folder row\'s own hover cluster' }, 'src/pages/chat/SubagentRunCard.tsx': { gesture: 1, announced: 1, keepTarget: 0, plain: 0, reason: 'run-card session link announces' }, 'src/pages/chat/WorkflowRunCard.tsx': { gesture: 1, announced: 1, keepTarget: 0, plain: 0, reason: 'run-card session link announces' }, diff --git a/website/src/test/ApiClient.refactor.surface.test.ts b/website/src/test/ApiClient.refactor.surface.test.ts index 992a0cd93d7..4af6201fcc0 100644 --- a/website/src/test/ApiClient.refactor.surface.test.ts +++ b/website/src/test/ApiClient.refactor.surface.test.ts @@ -68,7 +68,7 @@ const API_KEY_ORDER = [ 'deleteKirocrewAgent', 'appearances', 'uploadCrewAvatar', 'models', 'harnessModels', 'chatSlotSelectionCapabilities', 'effortLevels', 'slashCommands', 'chatSlotAgent', 'chatSlotModel', 'chatSlotAutocompact', 'setChatSlotAutocompact', 'chatSlotTurnTimeout', - 'setChatSlotTurnTimeout', 'chatSlotsModel', 'chatSlotsFollowDefaultCount', 'chatSlotsFollowDefault', + 'setChatSlotTurnTimeout', 'chatSlotsModel', 'chatSlotsFollowDefaultCount', 'chatSlotsFollowDefault', 'chatSlotsGenerateTitles', 'chatSlotsGenerateTitlesStatus', 'chatSlotReasoningEffort', 'chatSlotEnvironment', 'chatSlotEnvironmentList', 'chatSlotEnvironmentRead', 'chatSlotEnvironmentHealth', 'sessionEnvironmentsCheck', 'sessionEnvironmentsWorkspaces', 'sessionEnvironmentsTemplates', 'chatSlotEnvironmentWrite', diff --git a/website/src/test/ChatSidebar.bulkTitles.test.tsx b/website/src/test/ChatSidebar.bulkTitles.test.tsx new file mode 100644 index 00000000000..166580e5727 --- /dev/null +++ b/website/src/test/ChatSidebar.bulkTitles.test.tsx @@ -0,0 +1,385 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import { createTestStore } from './helpers' +import { ThemeProvider } from '../hooks/useTheme' +import type { RootState } from '../store' + +vi.mock('framer-motion', async () => { + const React = await import('react') + const FRAMER_PROPS = new Set([ + 'layout', 'layoutId', 'layoutScroll', 'initial', 'animate', 'exit', + 'transition', 'variants', 'whileHover', 'whileTap', 'whileInView', + 'drag', 'dragConstraints', 'dragElastic', 'onAnimationComplete', + ]) + const make = (tag: string) => + React.forwardRef((props: Record, ref: React.Ref) => { + const clean: Record = {} + for (const k of Object.keys(props)) { + if (k === 'children') continue + if (k === 'layoutId') { clean['data-layout-id'] = props[k]; continue } + if (FRAMER_PROPS.has(k)) continue + clean[k] = props[k] + } + return React.createElement(tag, { ...clean, ref }, props.children as React.ReactNode) + }) + const motion = new Proxy({}, { get: (_t, tag: string) => make(tag) }) + return { + motion, + AnimatePresence: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children), + } +}) + +vi.mock('../components/ProjectPicker', () => ({ default: () => null })) + +vi.mock('../pages/chat/ChatSettings', () => ({ + loadChatConfig: () => ({ tagColumnsEnabled: false, confirmCloseSession: false }), + saveChatConfig: vi.fn(), +})) + +const mocks = vi.hoisted(() => ({ + chatSlotsGenerateTitles: vi.fn(), + chatSlotsGenerateTitlesStatus: vi.fn(), + chatFolders: vi.fn(), + chatTags: vi.fn(), + tagColumns: vi.fn(), + kirocrewConfig: vi.fn(), + sessions: vi.fn(), +})) +vi.mock('../api/client', () => ({ + SEARCH_MIN_CHARS: 2, + api: new Proxy(mocks as unknown as Record, { + get: (target, prop: string) => (prop in target ? target[prop] : vi.fn().mockResolvedValue([])), + }), +})) + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + })), +}) + +import ChatSidebar from '../pages/ChatSidebar' + +const SLOTS = [ + { key: 'k-a', title: 'Idle A', running: false, messages: 1 }, + { key: 'k-b', title: 'Idle B', running: false, messages: 1 }, +] + +const storeRef: { current: ReturnType | null } = { current: null } + +function renderSidebar(slots: Record[] = SLOTS, onSelectSlot?: (key: string) => void) { + const defaults = createTestStore().getState() + const store = createTestStore({ + dashboard: { + ...defaults.dashboard, + status: {}, connected: true, slots, approvalMode: 'normal', + channelTrusted: false, refreshTrigger: 0, unreadSlots: [], updateProgress: null, + slotsLoaded: true, + subagentRunning: {}, subagentDetails: {}, subagentText: {}, + sessionDefaultColor: null, sessionColorsMode: 'tint', sessionColorsPalette: 'horizon', sessionColorsIntensity: 'clear', + } as unknown as RootState['dashboard'], + chat: { + ...defaults.chat, + activeSlot: null, slotStatusDetail: {}, subagents: {}, slotActivity: {}, + automations: {}, workflowRuns: {}, subagentQueued: {}, slotHistory: [], + revealRequest: null, revealNonce: 0, + } as unknown as RootState['chat'], + }) + storeRef.current = store + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + qc.setQueryData(['chat-folders'], []) + qc.setQueryData(['tag-columns'], []) + render( + + + + + + + + + , + ) +} + + +// The preview query waits for the status query, so the counts render after two chained fetches. +const PREVIEW_CHAIN = { timeout: 5000 } + +// Confirm starts the apply mutation, whose callbacks refetch the status (and the preview when nothing queued) before the asserted state renders. +const CONFIRM_CHAIN = { timeout: 5000 } + +const PLAN = { + ok: true, dry_run: true, queued: ['k-a', 'k-b'], skipped_running: ['k-c'], + skipped_renamed: ['k-d'], skipped_recent: [], skipped_empty: ['k-e'], renamed: 1, +} + +async function openPanel() { + fireEvent.keyDown(screen.getAllByLabelText('More options')[0], { key: 'Enter' }) + fireEvent.click(await screen.findByText('Regenerate all titles…')) + return screen.findByTestId('bulk-titles-panel') +} + +beforeEach(() => { + localStorage.clear() + mocks.chatFolders.mockResolvedValue([]) + mocks.chatTags.mockResolvedValue([]) + mocks.tagColumns.mockResolvedValue([]) + mocks.sessions.mockResolvedValue({ sessions: [], has_more: false }) + mocks.kirocrewConfig.mockResolvedValue({ dashboard: { recent_tint_count: 3 } }) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ ok: true, run: null }) +}) +afterEach(() => { + vi.clearAllMocks() +}) + +describe('ChatSidebar — Regenerate All Titles', () => { + it('previews the plan without spending anything', async () => { + mocks.chatSlotsGenerateTitles.mockResolvedValue(PLAN) + renderSidebar() + await openPanel() + const counts = await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + expect(counts.textContent).toContain('Sessions to retitle: 2') + expect(counts.textContent).toContain('Running, left alone: 1') + expect(counts.textContent).toContain('No conversation yet, left alone: 1') + expect(screen.getByText('Include sessions you renamed: 1')).toBeTruthy() + expect(mocks.chatSlotsGenerateTitles).toHaveBeenCalledTimes(1) + expect(mocks.chatSlotsGenerateTitles).toHaveBeenCalledWith({ includeRenamed: false, dryRun: true }) + }) + + it('re-previews with renamed sessions when the box is ticked', async () => { + mocks.chatSlotsGenerateTitles.mockResolvedValue(PLAN) + renderSidebar() + await openPanel() + fireEvent.click(await screen.findByRole('checkbox', { name: 'Include sessions you renamed: 1' }, PREVIEW_CHAIN)) + await waitFor(() => + expect(mocks.chatSlotsGenerateTitles).toHaveBeenCalledWith({ includeRenamed: true, dryRun: true }), + ) + }) + + it('starts the run and says how many sessions it queued', async () => { + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? PLAN : { ...PLAN, dry_run: false, run_id: 7 }), + ) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const started = await screen.findByTestId('bulk-titles-started', {}, CONFIRM_CHAIN) + expect(started.textContent).toContain('Started on 2 sessions') + expect(mocks.chatSlotsGenerateTitles).toHaveBeenLastCalledWith({ includeRenamed: false, dryRun: false }) + fireEvent.click(screen.getByText('Close')) + expect(screen.queryByTestId('bulk-titles-panel')).toBeNull() + }) + + it('reads in the singular when one session was queued', async () => { + const one = { ...PLAN, queued: ['k-a'] } + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? one : { ...one, dry_run: false, run_id: 8 }), + ) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const started = await screen.findByTestId('bulk-titles-started', {}, CONFIRM_CHAIN) + expect(started.textContent).toContain('Started on 1 session.') + }) + + it('counts the run down and reports how it ended', async () => { + const run = { id: 7, total: 3, done: 1, retitled: 1, unchanged: 0, failed: 0, failed_sessions: [], finished: false } + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? PLAN : { ...PLAN, dry_run: false, run_id: 7 }), + ) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ ok: true, run }) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const progress = await screen.findByTestId('bulk-titles-progress', {}, CONFIRM_CHAIN) + expect(progress.textContent).toBe('Done: 1 of 3') + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { ...run, done: 3, retitled: 1, unchanged: 1, failed: 1, failed_sessions: [{ key: 'k-b', title: 'Idle B' }], finished: true }, + }) + const finished = await screen.findByTestId('bulk-titles-finished', {}, { timeout: 3000 }) + expect(finished.textContent).toContain('Finished. Retitled: 1') + expect(finished.textContent).toContain('Left as they were: 1') + expect(finished.textContent).not.toContain('Failed: 1') + expect(screen.getByTestId('bulk-titles-failed-count').textContent).toContain('Failed: 1') + expect(screen.queryByTestId('bulk-titles-progress')).toBeNull() + }) + + it('follows a run that replaced its own and reports how that one ended', async () => { + const own = { id: 7, total: 3, done: 1, retitled: 1, unchanged: 0, failed: 0, failed_sessions: [], finished: false } + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? PLAN : { ...PLAN, dry_run: false, run_id: 7 }), + ) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ ok: true, run: own }) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + await screen.findByTestId('bulk-titles-progress', {}, CONFIRM_CHAIN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { id: 8, total: 4, done: 2, retitled: 2, unchanged: 0, failed: 0, failed_sessions: [], finished: false }, + }) + await waitFor(() => expect(screen.getByTestId('bulk-titles-progress').textContent).toBe('Done: 2 of 4'), { timeout: 3000 }) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { id: 8, total: 4, done: 4, retitled: 4, unchanged: 0, failed: 0, failed_sessions: [], finished: true }, + }) + const finished = await screen.findByTestId('bulk-titles-finished', {}, { timeout: 3000 }) + expect(finished.textContent).toContain('Finished. Retitled: 4') + }) + + it('names each session that failed and opens it on click', async () => { + const onSelectSlot = vi.fn() + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, + run: { + id: 4, total: 2, done: 2, retitled: 0, unchanged: 0, failed: 2, + failed_sessions: [{ key: 'k-a', title: 'Idle A' }, { key: 'k-b', title: '' }], + finished: true, + }, + }) + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? PLAN : { ...PLAN, dry_run: false, run_id: 4 }), + ) + renderSidebar(SLOTS, onSelectSlot) + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const names = await screen.findByTestId('bulk-titles-failed-sessions', {}, { timeout: 3000 }) + const buttons = within(names).getAllByRole('button') + expect(buttons.map(b => b.textContent)).toEqual(['Idle A', 'Untitled session']) + fireEvent.click(buttons[1]) + expect(onSelectSlot).toHaveBeenCalledWith('k-b') + await waitFor(() => expect(storeRef.current?.getState().chat.activeSlot).toBe('k-b')) + }) + + it('scrolls a long failure list and keeps Close outside it', async () => { + const failed_sessions = Array.from({ length: 30 }, (_, i) => ({ key: `k-${i}`, title: `Session ${i}` })) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, + run: { id: 5, total: 30, done: 30, retitled: 0, unchanged: 0, failed: 30, failed_sessions, finished: true }, + }) + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + Promise.resolve(dryRun ? PLAN : { ...PLAN, dry_run: false, run_id: 5 }), + ) + renderSidebar() + const panel = await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const names = await screen.findByTestId('bulk-titles-failed-sessions', {}, { timeout: 3000 }) + expect(names.className).toContain('overflow-y-auto') + expect(names.className).toMatch(/max-h-/) + const close = within(panel).getByRole('button', { name: 'Close' }) + expect(names.contains(close)).toBe(false) + }) + + it('shows the run in flight instead of a new preview when reopened', async () => { + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { id: 3, total: 4, done: 2, retitled: 2, unchanged: 0, failed: 0, failed_sessions: [], finished: false }, + }) + renderSidebar() + await openPanel() + const progress = await screen.findByTestId('bulk-titles-progress') + expect(progress.textContent).toBe('Done: 2 of 4') + expect(screen.queryByTestId('bulk-titles-confirm')).toBeNull() + expect(mocks.chatSlotsGenerateTitles).not.toHaveBeenCalled() + }) + + it('keeps showing a run reopened mid-flight until its summary', async () => { + const run = { id: 3, total: 2, done: 1, retitled: 1, unchanged: 0, failed: 0, failed_sessions: [], finished: false } + mocks.chatSlotsGenerateTitles.mockResolvedValue(PLAN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ ok: true, run }) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-progress') + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ ok: true, run: { ...run, done: 2, retitled: 2, finished: true } }) + const finished = await screen.findByTestId('bulk-titles-finished', {}, { timeout: 3000 }) + expect(finished.textContent).toContain('Finished. Retitled: 2') + expect(screen.queryByTestId('bulk-titles-counts')).toBeNull() + }) + + it('switches to the other run when a second confirm is refused', async () => { + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => + dryRun ? Promise.resolve(PLAN) : Promise.reject(new Error('409')), + ) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { id: 9, total: 5, done: 2, retitled: 2, unchanged: 0, failed: 0, failed_sessions: [], finished: false }, + }) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + const progress = await screen.findByTestId('bulk-titles-progress', {}, CONFIRM_CHAIN) + expect(progress.textContent).toBe('Done: 2 of 5') + }) + + it('previews as usual when the last run already finished', async () => { + mocks.chatSlotsGenerateTitles.mockResolvedValue(PLAN) + mocks.chatSlotsGenerateTitlesStatus.mockResolvedValue({ + ok: true, run: { id: 3, total: 1, done: 1, retitled: 1, unchanged: 0, failed: 0, failed_sessions: [], finished: true }, + }) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + expect(screen.queryByTestId('bulk-titles-finished')).toBeNull() + }) + + it('cannot confirm when nothing would be retitled', async () => { + mocks.chatSlotsGenerateTitles.mockResolvedValue({ ...PLAN, queued: [] }) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + expect((screen.getByTestId('bulk-titles-confirm') as HTMLButtonElement).disabled).toBe(true) + }) + + it('shows a notice when the preview fails', async () => { + mocks.chatSlotsGenerateTitles.mockRejectedValue(new Error('500')) + renderSidebar() + await openPanel() + const notice = await screen.findByTestId('bulk-titles-preview-error', {}, PREVIEW_CHAIN) + expect(notice.textContent).toContain("Couldn't count the sessions to retitle.") + expect((screen.getByTestId('bulk-titles-confirm') as HTMLButtonElement).disabled).toBe(true) + }) + + it('goes back to the preview when the apply queued nothing', async () => { + const later = { ...PLAN, queued: [], skipped_running: ['k-a', 'k-b', 'k-c'] } + let applied = false + mocks.chatSlotsGenerateTitles.mockImplementation(({ dryRun }: { dryRun: boolean }) => { + if (!dryRun) { + applied = true + return Promise.resolve({ ...later, dry_run: false, run_id: null }) + } + return Promise.resolve(applied ? later : PLAN) + }) + renderSidebar() + await openPanel() + await screen.findByTestId('bulk-titles-counts', {}, PREVIEW_CHAIN) + fireEvent.click(screen.getByTestId('bulk-titles-confirm')) + await waitFor(() => expect(screen.getByTestId('bulk-titles-counts').textContent).toContain('Running, left alone: 3'), CONFIRM_CHAIN) + expect(screen.queryByTestId('bulk-titles-started')).toBeNull() + expect((screen.getByTestId('bulk-titles-confirm') as HTMLButtonElement).disabled).toBe(true) + }) + + it('shows a notice when the progress cannot be read', async () => { + mocks.chatSlotsGenerateTitles.mockResolvedValue(PLAN) + mocks.chatSlotsGenerateTitlesStatus.mockRejectedValue(new Error('500')) + renderSidebar() + await openPanel() + const notice = await screen.findByTestId('bulk-titles-status-error') + expect(notice.textContent).toContain("Couldn't read the title progress.") + }) +}) diff --git a/website/src/test/ChatSidebar.ownerComposition.test.ts b/website/src/test/ChatSidebar.ownerComposition.test.ts index 22eb254f4d5..d2c2668a189 100644 --- a/website/src/test/ChatSidebar.ownerComposition.test.ts +++ b/website/src/test/ChatSidebar.ownerComposition.test.ts @@ -110,7 +110,7 @@ describe('the facade composition', () => { // before the owners were extracted, which is what keeps React's effect order. const CALL_ORDER = [ 'useSessionSources', 'useDebouncedSessionSearch', 'useSessionRename', 'useSessionAutoTitle', 'useSessionSort', 'useSidebarLane', - 'useSessionFilterState', 'useSessionStatusFilters', 'useHistoryPane', 'useSessionCleanup', 'useBulkModelSwitch', 'usePinnedSessionOrder', + 'useSessionFilterState', 'useSessionStatusFilters', 'useHistoryPane', 'useSessionCleanup', 'useBulkModelSwitch', 'useBulkTitles', 'usePinnedSessionOrder', 'useManualSessionOrder', 'useStaleCollapse', 'useFolderSort', 'useFolderRename', 'useSidebarTags', 'useSidebarModels', 'useBoardColumns', 'useSidebarResize', 'useFullTitlesOnScrollSetting', 'usePinnedOrderAuthority', 'useColumnPopover', 'useBoardColumnMutations',