Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/memory/architecture/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,7 @@ the row of the agent whose new chat is **already on stage** is a no-op in `openA
first message without `new_thread`, i.e. into Main. And a landing that **reuses a chat**
(the empty one, a remembered one) mounts as a thread, which focuses nothing, so the shell
hands the caret over under the same pointer rule (`focusLandedComposer`). Main is named by its role in both the tab strip and the header and is
not renameable; an archived chat stays a tab (ruled “becomes the newest tab”), though you never LAND in one by default;
not renameable; an archived chat is NOT a tab and is not in the sidebar's active groups — one rule for a chat closed from its tab × and for Reset's retired Main (ent#841, reversing the ent#523 ruling) — it is listed under the sidebar's **Archived** group and opening it reopens it (`PUT`/`DELETE …/sessions/{id}/archive`; the live Main is a named 409). Local star and close writes are held over any list read issued before they settled (`portalUtils.createWriteGuard`, #3356), because `refreshThreads` replaces whole maps and a stale read otherwise undoes the click;
an unused Main is filtered from the **sidebar** only (a projection, not a filter on
`threads`, because the strip must show Main from the first visit).

Expand Down
4 changes: 2 additions & 2 deletions docs/memory/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,15 @@ laid out at its intrinsic width — which for a strip of **unbounded** labels (u
model text, today only the Workspace chat tabs) means one long title stretches its tab
and pushes every sibling under "N more". Such a strip may opt into `fixedWidth`.

**Recipe:** every tab `FIXED_TAB_WIDTH` (`w-40` / 160px) and `shrink-0`, Main and short
**Recipe:** every tab `FIXED_TAB_WIDTH` (`w-48` / 192px) and `shrink-0`, Main and short
labels included — uniform width is the point, so an ellipsis is a property of the
content, not a jagged strip; the label clamps in a `min-w-0 truncate` span; the full
text rides `title=` on the button **and** on the overflow-menu row, so nothing is
recoverable only by widening the window.

- **Do:** opt in per consumer; keep every width class behind the prop.
- **Don't:** make it the default, couple it to `dense`, or clamp a strip of short fixed
labels — a tooltip on "Overview" is noise, and a 160px "Files" tab is waste.
labels — a tooltip on "Overview" is noise, and a 192px "Files" tab is waste.
- **Watch:** the width class belongs in **both** rows (a mirror that measures narrower
than the visible row overflows one tab too late), but `shrink-0` and the visible nav's
`overflow-hidden` belong to the **visible** row only. `inlineCount` starts at
Expand Down
10 changes: 5 additions & 5 deletions docs/memory/feature-flows/workspace-agents-at-the-centre.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ rule is a plain function (the ent#392 precedent):

| Function | Rule |
|---|---|
| `agentChatTabs` | Main first, then recency; an archive stays a tab (ruled “becomes the newest tab”); Main is labelled by its **role** |
| `agentChatTabs` | Main first, then recency; an archived chat is not a tab unless it is on screen (ent#841 — it lives in the sidebar's Archived group); every chat but Main is `closable`; Main is labelled by its **role** |
| `agentLanding` | a **precedence** (ent#784, replacing ent#523's `landingThread`; operator ruling 2026-10-05): a validated `lastOpenSessionId` (live, unarchived, this agent's), then the chat holding an unsent **draft** (newest `updatedAt`; a `new:<agent>` winner = a new chat), then the agent's existing **empty** chat, then a new, empty chat |
| `resolveAgentLanding` | the `?agent=` deep link, delegating to `agentLanding` so there is one answer |
| `agentEmptyChat` | arm 4's "empty": unarchived, non-room, no `last_message_at` and `message_count` 0/absent; Main first, then newest `created_at` |
Expand All @@ -215,10 +215,10 @@ rule is a plain function (the ent#392 precedent):
- **Main is not renameable.** It is the same thread for the life of the pair;
a derived or typed title would make the pinned tab and the header disagree
about which chat you are in.
- **A tab is not a landing.** An archived chat stays in the strip — the operator
ruled it "becomes the newest tab", and hiding what the system line just pointed
at is exactly where the person looks next — but `agentLanding` never puts you
in one without asking. Two rules, two questions: where can I go, and where am
- **A tab is not a landing.** `agentLanding` never puts you in an archived chat
without asking — and since ent#841 an archived chat is not in the strip either
(one archive rule for a closed chat and a Reset archive; both are found in the
sidebar's Archived group, superseding the ent#523 "becomes the newest tab" ruling). Two rules, two questions: where can I go, and where am
I put. Since ent#784 the landing answer is a new chat unless something of the
person's own is waiting (a draft, or an empty chat already open), so the strip
is the ONLY way back into a USED one — which is the point: continuity is one
Expand Down
23 changes: 12 additions & 11 deletions docs/memory/feature-flows/workspace-chat-tabs-and-titles.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ PortalConversation.vue
<header> [agent picker] [title, renameable] [+ New chat ⌘J] [★] [voice] [files]
<slot #band> [the agent's numbers] ← the shell mounts it
<PortalChatTabs> 🔖 Main | New chat | Q3 invoi… | Onboardi… | 3 more ▾
└─ OverflowTabs, dense + fixed-width (160px each)
└─ OverflowTabs, dense + fixed-width (192px each)
<slot #notice> ⚠ Workspace chat titles aren't being generated … [Dismiss]
└─ the shell mounts it; platform admins only (#2579)
<thread>
Expand Down Expand Up @@ -325,14 +325,15 @@ no routing at all: once the agent page IS the conversation, a recent-chat row's
existing `/workspace/c/:sid` push is already "the agent page with that chat
active". `agentChatTabs` gained the Main pin.

**An archived chat IS a tab.** An earlier draft filtered them out, reasoning
that Reset would grow the strip by one permanent entry per use; the operator
ruled the other way — "one system line in Main names the archived chat, which
becomes the newest tab" — and that draft was reverted. It was solving a problem
`OverflowTabs` already solves: the strip renders what fits and counts the rest.
You simply never LAND in an archived chat by default. (A stale line here said
the opposite until #2579; the code never did.) See
[workspace-agents-at-the-centre.md](workspace-agents-at-the-centre.md).
**An archived chat is NOT a tab (ent#841, 2026-10-08).** ent#523 ruled that
Reset's archive "becomes the newest tab". ent#841 added a close × to every tab
(archive, never delete), and a tab that came straight back would be a close that
did nothing — so the operator ruled ONE archive rule: any archived chat, a
retired Main included, leaves the strip and the sidebar's active groups and is
found under the sidebar's **Archived** group; opening it there reopens it. The
chat on screen keeps its tab even when archived. The strip also carries each
background chat's unread count (primary blue, the sidebar row's badge). See
requirements §5.41.

## The four defects #2579 fixed

Expand Down Expand Up @@ -376,7 +377,7 @@ has not listed yet would wear a "New chat" label over a real conversation.

`OverflowTabs` takes an opt-in `fixedWidth` (default **false**; the three other
consumers pass nothing and are byte-identical). Under it every tab is
`FIXED_TAB_WIDTH` (`w-40`, 160px, **Main included** — the operator ruled the
`FIXED_TAB_WIDTH` (`w-48`, 192px — widened from 160 on 2026-10-08 to carry the star, the unread count and the ×, **Main included** — the operator ruled the
width uniform), the label clamps in a `min-w-0 truncate` span, and the full text
rides `title=` on the button and on the overflow-menu row.

Expand Down Expand Up @@ -593,7 +594,7 @@ never-opened chat count at all.
the tabs). `portalRosterRow.spec.js` mounts the sidebar for the bound and the
handed-down order — the lift's own regression.
- `src/frontend/e2e/workspace-chat-tabs.spec.js` (**#2579**) — the geometry no
node-env pin can execute: every visible tab exactly 160px (Main included), a
node-env pin can execute: every visible tab exactly 192px (Main included), a
long title clipped with its full text on `title=`, the counted "N more"
appearing as the column narrows, and no horizontal page overflow. Learning
#1500 is why it exists — the last structural change to a shared tab strip
Expand Down
58 changes: 54 additions & 4 deletions docs/memory/requirements/core-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -2481,10 +2481,11 @@ issue if it's ever wanted. Also deferred: `data.json` caching/streaming.
names the two properties the tab form had to answer for (participant scoping,
and dismissal vs switching) — §5.30 answers both.
- Main is named by its **role** in the tab strip and the header and is not
renameable. An archived chat stays a tab — the operator ruled it “becomes the
newest tab” — and growth is bounded by `OverflowTabs`' counted “N more” rather
than by hiding rows; you simply never LAND in one by default. An unused Main is
filtered from the **sidebar** only.
renameable. ~~An archived chat stays a tab — the operator ruled it “becomes the
newest tab”.~~ ⚠️ **Superseded 2026-10-08 by §5.41** (ent#841): an archived
chat — Reset's retired Main included — leaves the strip and the sidebar's
active groups and is listed under **Archived**. An unused Main is filtered
from the **sidebar** only (unless it is starred, #3356).
- The sidebar orders agents by most recent collaboration then name, applied
before the collapse. This is **not** ent#491 (incubating): `orderRosterAgents`
ships the order this AC states and leaves `primaryName` as its seam.
Expand Down Expand Up @@ -3912,3 +3913,52 @@ to localStorage in the clear.
- **Not in scope**: the editable "What would be sent" (09-20 ruling); #609; the `people_for`
provider; `may_end` (rides #164); run pills with step labels (need a message→execution link).
- **Flow**: `docs/memory/feature-flows/workspace-inbox.md`

### 5.41 Workspace — close a chat from its tab, the tab's unread count, and a star that sticks (trinity-enterprise#841, abilityai/trinity#3356)
- **Status**: ✅ Implemented (2026-10-08)
- **Requirement ID**: WORKSPACE_CLOSE_CHAT
- **GitHub Issue**: abilityai/trinity-enterprise#841, abilityai/trinity#3356
- **Description**: Closing a chat takes one click: an **×** on its tab.
Closing **archives** the chat and never deletes it.
- **Key Features**:
- Every chat tab but Main carries an × ("Archive chat"): revealed on
hover/focus from `sm:` up, always drawn on touch, a real button reachable by
keyboard, also on the rows of the strip's "N more" menu. One click, no
confirmation — nothing is lost. **Main cannot be closed** (no ×; the server
answers a named 409) — Reset is its retire action.
- **One archive rule (operator, 2026-10-08).** Closing sets the same
`archived_at` Reset's retired Main carries, and *every* archived chat leaves
the tab strip and the sidebar's active groups (Starred and the date groups).
This reverses §5.23's ent#523 ruling that a Reset archive "becomes the newest
tab". The chat on screen is the one exception — a deep link to an archived
chat still draws its tab.
- **Found and reopened.** The sidebar lists archived chats in a collapsed
**Archived · N** group. Opening one reopens it (clears `archived_at`) as an
ordinary active chat; a retired Main reopens with `is_main = 0`.
- **Undo.** A "Chat archived · Undo" toast follows a successful close (8 s);
Undo reopens the chat and returns to it if it was the open one. A refused
close reverts the tab and names the reason in an `InlineError` under the strip.
- **Neighbour on close.** Closing the open chat moves to the tab on its
right, else its left (Main is first, so there is one), else the agent page.
- **Starred chats** can be closed: the star (per-viewer chat state) is not
touched, the chat leaves Starred while archived, and reopening restores its
pinned place.
- **A reply in flight.** The × of the OPEN chat is inert while its reply is in
flight, with a tooltip saying why. A background chat can be closed mid-turn:
the reply still lands in the archived chat and is there on reopen.
- **Rooms are excluded**: the strip lists one agent's 1:1 chats, so a room has
no tab to close; a room keeps its own lifecycle.
- **Unread on the tab.** A chat with unread replies that is not on screen
shows the sidebar row's count on its tab, in the same solid blue
(`badgeVariant: 'primary'`), and in the "N more" menu. The strip is
fixed-width, so the count shortens that tab's label and moves nothing.
- **The star on the tab.** A starred chat's tab draws a filled star before its
label (the tab's own ink), in the strip and in the "N more" menu. Tabs are
192px (`w-48`, was 160) to carry the star, the count and the ×.
- **A star sticks on the first click (#3356).** A chat-state read already in
flight when the star is clicked used to answer with the pre-click state and
overwrite it. Local star writes (and tab closes) are now held over any read
issued before they settled (`createWriteGuard`). A starred, never-used Main
is listed in the sidebar so the header star has somewhere to show.
- **API**: `PUT` / `DELETE /api/enterprise/client-portal/agents/{name}/sessions/{id}/archive`
(roster- then session-scoped, uniform 404, idempotent, 60/min per viewer). No schema change.
33 changes: 33 additions & 0 deletions src/backend/client_portal/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,39 @@ def rename_portal_session(session_id: str, agent_name: str, client_email: str,
}).rowcount or 0) > 0


def set_portal_session_archived(session_id: str, agent_name: str, client_email: str,
archived: bool, now: str) -> bool:
"""Close (archive) or reopen one thread (ent#841). Nothing is deleted:
``archived_at`` is the same flag Reset's retired Main carries, and every
message, file and star stays where it was.

Scoped to (agent, client) in the UPDATE itself, like `rename_portal_session`,
so an unowned id is a miss and the router's uniform 404 (Invariant #8).
``is_main = 0`` is part of the predicate: the live Main is never closed this
way (Reset is its retire action), and a retired Main is reopened as an
ordinary chat because ``is_main`` stays 0 — the pair already has a new Main.
Archiving an already-archived row keeps its first ``archived_at``.
"""
if archived:
stmt = text(
"UPDATE enterprise_portal_sessions "
"SET archived_at = COALESCE(archived_at, :now) "
"WHERE id = :id AND agent_name = :agent AND client_email = :email "
" AND is_main = 0"
)
else:
stmt = text(
"UPDATE enterprise_portal_sessions SET archived_at = NULL "
"WHERE id = :id AND agent_name = :agent AND client_email = :email "
" AND is_main = 0"
)
with get_engine().begin() as conn:
return (conn.execute(stmt, {
"id": session_id, "agent": agent_name, "now": now,
"email": (client_email or "").lower(),
}).rowcount or 0) > 0


def touch_portal_session(session_id: str, now: str, added: int = 2,
title_if_empty: Optional[str] = None) -> None:
"""After a turn: advance ``last_message_at``, add ``added`` to the count, and
Expand Down
30 changes: 30 additions & 0 deletions src/backend/client_portal/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,36 @@ def portal_rename_session(agent_name: str, session_id: str, body: PortalSessionR
raise HTTPException(status_code=e.status_code, detail=e.detail)


@router.put("/agents/{agent_name}/sessions/{session_id}/archive",
response_model=PortalSessionSummary)
def portal_archive_session(agent_name: str, session_id: str,
principal: PortalPrincipal = Depends(get_portal_principal)):
"""Close a chat (ent#841): archive it, never delete it. Idempotent."""
return _set_session_archived(agent_name, session_id, principal, True)


@router.delete("/agents/{agent_name}/sessions/{session_id}/archive",
response_model=PortalSessionSummary)
def portal_unarchive_session(agent_name: str, session_id: str,
principal: PortalPrincipal = Depends(get_portal_principal)):
"""Reopen a closed chat (ent#841) — the Undo, and opening it from Archived."""
return _set_session_archived(agent_name, session_id, principal, False)


def _set_session_archived(agent_name: str, session_id: str,
principal: PortalPrincipal, archived: bool):
from services import rate_limiter

# A write, bounded per viewer like rename (one request per click).
rate_limiter.enforce(f"portal_archive:{principal.email}", 60, 60)
try:
return service.set_session_archived(
agent_name, principal.email, session_id, archived,
include_owned=principal.is_platform)
except ClientPortalError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)


@router.get("/agents/{agent_name}/history", response_model=PortalHistory)
def portal_history(agent_name: str, session_id: str | None = None,
limit: int | None = Query(None, ge=1, le=50),
Expand Down
31 changes: 31 additions & 0 deletions src/backend/client_portal/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4423,6 +4423,37 @@ def rename_session(agent_name: str, email: str, session_id: str, title,
}


def set_session_archived(agent_name: str, email: str, session_id: str,
archived: bool, include_owned: bool = False) -> dict:
"""Close a chat from its tab (ent#841), or reopen it. Archive, never delete.

Roster-scoped (miss → 404), then the UPDATE is scoped to (agent, client) so
an unowned or unknown id is the same uniform 404 (Invariant #8). The live
Main is refused with a named 409 rather than a 404: it exists and is
theirs, and the person needs to be told that Reset is its retire action.
The star (per-viewer chat state) is untouched, so a starred chat that is
closed and reopened comes back starred.
"""
if not agent_on_roster(agent_name, email, include_owned):
raise ClientPortalError(404, "Agent not found")
row = db.get_portal_session(session_id, agent_name, email)
if not row:
raise ClientPortalError(404, "Conversation not found")
if row.get("is_main"):
raise ClientPortalError(409, "Main can't be closed — use Reset to start it fresh")
db.set_portal_session_archived(session_id, agent_name, email, archived, utc_now_iso())
row = db.get_portal_session(session_id, agent_name, email) or row
return {
"id": session_id,
"title": row.get("title"),
"created_at": row.get("created_at"),
"last_message_at": row.get("last_message_at"),
"message_count": int(row.get("message_count") or 0),
"is_main": bool(row.get("is_main")),
"archived_at": row.get("archived_at"),
}


_SEARCH_MIN_LEN = 2 # a 1-char query is too noisy to be useful
_SNIPPET_RADIUS = 60 # chars of context on each side of the match

Expand Down
Loading
Loading