Skip to content

feat(workspace): chats as tabs, ⌘J New chat, and renameable titles (abilityai/trinity-enterprise#451, abilityai/trinity-enterprise#473) - #2552

Merged
vybe merged 1 commit into
devfrom
feature/ent451-473-chat-tabs-rename
Sep 6, 2026
Merged

feat(workspace): chats as tabs, ⌘J New chat, and renameable titles (abilityai/trinity-enterprise#451, abilityai/trinity-enterprise#473)#2552
vybe merged 1 commit into
devfrom
feature/ent451-473-chat-tabs-rename

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

Journey Impact: none — UI plus two endpoints inside the existing Workspace chat surface; no journey in the current deck (J01–J10) covers Workspace chat (the same finding as ent#451 / ent#473).

Fixes abilityai/trinity-enterprise#451
Fixes abilityai/trinity-enterprise#473

Summary

Two rulings from the 2026-09-06 operator session, one PR.

ent#451 — the remaining slice (#2430 shipped the fresh-thread half):

  • The agent's chats render as tabs above the threadOverflowTabs, never a hand-rolled strip, with two additions to the primitive: dense and a counted moreLabel ("N more"; every existing strip keeps "More"). Most recent first, a slice of the sidebar's own list (no second fetch), repacking on rail/window resize because the primitive re-measures on ResizeObserver. An unsaved new chat is not a tab (ruling: it exists once its first message is sent), and the pinned Main tab is bug: claude subprocess stdout inherited by MCP child processes — leaks reader threads, eventually wedges agent-server #523's first slot.
  • New chat moves into the conversation header with ⌘J / Ctrl+J — plain modifier only; armed on window at mount above bootstrap()'s await (contract Orphaned Execution Recovery #23); resolves the agent in front of the person (page or conversation), the picker otherwise.

ent#473 — rename, and trustworthy generated titles:

  • PATCH /api/enterprise/client-portal/agents/{agent}/sessions/{id} {title} and PATCH /api/rooms/{room_id} {name}. ONE validator leaf (services/chat_title.py: trim, collapse, control chars dropped, an inner line break refused, non-empty, ≤100) imported by both services, so a thread and a room refuse the same titles with the same named 400 ({code: "invalid_title", reason, message} — rule, fix, example). Thread: roster gate, then the UPDATE itself is (agent, client)-scoped → uniform 404 (Invariant security: implement safe tar extraction with symlink/hardlink validation #8), per-viewer rate limit. Room: membership (uniform 404), then person-only — a member agent talks, it does not rename (403 not_a_person, the ent#220 line); thin room_renamed broadcast with the id only (feat: agent-reported structured reports via MCP + dashboard display #918).
  • enterprise_portal_sessions.title_source (NULL · generated · user; SQLite portal_session_title_source + Alembic 0052, no backfill). The generated write is guarded in the UPDATE (title_source != 'user'): generation runs off the reply path, so a rename typed inside the first turn's 15 s window races the model's guess, and a read-then-write would leave exactly that window.
  • _title_plan(row, history): first on an empty title (ent#186, unchanged); retry exactly once, on the exchange after the opener (message_count <= 2), when the first attempt never landed or the opener was greeting-shaped (is_greeting); never over a person's title. The retry feeds this exchange — the first one with a topic in it.
  • A failing generator is observable once: an in-process health record warns on the transition into no_credential (first hit) or failing (3 in a row), stays quiet in it, re-arms on recovery, and rides GET /api/settings/portal-session-policytitle_generation into the Workspace sessions settings panel as a warning notice with the next action.
  • PortalEditableTitle.vue is the one editor for its three homes (sidebar row · 1:1 header · room header): Enter/blur commit, Esc abandons, client-side mirror of the validator, server refusal rendered verbatim in an InlineError, optimistic list update with revert-and-rethrow.

Also (found while verifying AC 5 live): search_chats read only the shared roster, so a platform user searching chats with their own agents always got nothing. It now resolves the same set agent_on_roster enforces (roster_agent_names(email, include_owned)).

Changes

  • Backend: services/chat_title.py (new leaf), client_portal/{db,service,router,models}.py, shared_sessions/{db,service,router,models}.py, routers/settings.py, db/{schema,tables,migrations}.py, migrations/versions/0052_portal_session_title_source.py
  • Frontend: components/OverflowTabs.vue (dense, moreLabel), portal/PortalChatTabs.vue + portal/PortalEditableTitle.vue (new), PortalConversation.vue, PortalChatRow.vue, PortalSidebar.vue, PortalRoom.vue, portalUtils.js, stores/clientPortal.js, views/Portal.vue, settings/PortalSessionPolicyPanel.vue
  • Docs: requirements §5.21, architecture/workspace.md, feature-flows/workspace-chat-tabs-and-titles.md + index, learnings.md

Not in this PR (→ #523)

The pinned Main chat, Reset, the merged agent page, and the sidebar's "a recent-chat row opens the agent page with that chat active" — until then a row opens the thread view, which now carries the agent's tabs. No MCP rename tool: rename is a person's verb on the UI.

Test Plan

  • pytest tests/unit/test_ent473_chat_titles.py (53) — validator table, greeting shape, _title_plan decision table, the UPDATE guard against a real sqlite, rename scoping, search-by-user-title, named 400 at the router, route registration, room rules (person lands + thin broadcast, workspace client is a person, agent refused, same 400), health episodes (once / threshold / recovery / no key material), settings payload, both migration tracks, search roster scope
  • tests/unit/test_ent79_portal_exposure.py — the second pass end to end through portal_chat (firstretry → nothing; nothing after a landed topic title)
  • Neighbours: test_ent451/359/358/443/360/457/525, test_2198/2133/2320/2213, migrations, Alembic parity + revision-id + heads guards, auth-wiring / enumeration / models-centralized / settings-sink / admin-gate static guards — 531 passed
  • npm run test:unit1985 passed (incl. new portalChatTabsAndTitles.spec.js); npm run check:tokens; vite build
  • Live on the Docker stack (backend --reload): migration applied (title_source present, schema_migrations row); rename endpoints — multiline/too-long → 400 invalid_title with the sentence, valid → 200 normalised, unknown id → uniform 404, room empty → 400, room unknown → 404; sidebar rows carry the pencil; tab strip renders with the active tab; header rename → sidebar + tab + header update, server persisted; sidebar rename with an empty draft → inline refusal, row does not open, Esc abandons; ⌘J → fresh chat, no active tab; room row opens the room, header rename persisted; search finds the renamed thread
  • Reviewer: light + dark pass over the tab strip, the header title and the sidebar editor

Ratchets

Raw-color: only gray + semantic tokens added (status-warning-* for the notice). Loading-gate ratchet spec green. Alembic: one head (0052), parity guard PASS.

🤖 Generated with Claude Code

https://claude.ai/code/session_014TKZeR6GGoLQaZBo5sBUBu

…bilityai/trinity-enterprise#451, abilityai/trinity-enterprise#473)

The remaining ent#451 slice as ruled 2026-09-06, plus ent#473, in one PR.

ent#451: this user's threads with the active agent render as OverflowTabs
above the thread (dense + a counted moreLabel → "N more"; a slice of the
sidebar's list, never a second fetch; an unsaved new chat is not a tab; the
pinned Main chat is #523's first slot). New chat moves into the conversation
header with ⌘J / Ctrl+J — plain modifier only, armed on window above
bootstrap()'s await, resolving the agent in front of the person.

ent#473: PATCH .../agents/{agent}/sessions/{id} and PATCH /api/rooms/{id},
validated by ONE leaf (services/chat_title.py: trim, collapse, an inner line
break refused, ≤100) with the same named 400 (invalid_title + reason + a
sentence with an example). The thread UPDATE is (agent, client)-scoped; the
room is membership-then-person (a member agent talks, it does not rename).
enterprise_portal_sessions.title_source (NULL · generated · user; SQLite
migration + Alembic 0052, no backfill) and the generated write GUARDED IN
THE UPDATE, because generation runs off the reply path and races a rename
typed inside the first turn's window. _title_plan earns exactly one more
pass after a greeting-shaped opener or a first attempt that never landed
(message_count <= 2), never over a person's title. A failing generator warns
once per episode and rides GET /api/settings/portal-session-policy →
title_generation into the Workspace sessions panel's notice.
PortalEditableTitle.vue is the one editor for the sidebar row, the 1:1
header and the room header.

Also: search_chats now resolves the same roster set agent_on_roster
enforces (owned agents for a platform session) — it read only the shared
roster, so an owner searching their own agents' chats always got nothing,
which made AC 5 untestable on the operator's door.

Verification: tests/unit/test_ent473_chat_titles.py (53) + the portal /
rooms / migration neighbours (531 passed), Alembic heads guard, full
npm run test:unit (1985), check:tokens, vite build; live on the Docker
stack — migration applied on reload, rename endpoints (named 400s, uniform
404s, 200s), tab strip, header rename, sidebar rename with the inline
refusal, ⌘J, room rename, search by the new title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TKZeR6GGoLQaZBo5sBUBu

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: docs (requirements §5.21, workspace.md, feature flow + index) present; both migration tracks (SQLite portal_session_title_source + Alembic 0052, one head); security greps clean; backend-unit-test head 14014 passed (+55 over base), frontend build/test:unit/check:tokens green, e2e green; structural pass on the rename/scoping/UPDATE-guard paths reads correct (roster→uniform 404, (agent,client)-scoped UPDATE, person-only room rename, thin #918 broadcast).

@vybe
vybe merged commit ae444eb into dev Sep 6, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants