diff --git a/docs/memory/architecture/workspace.md b/docs/memory/architecture/workspace.md index b5f70f0f1d..e1586604a9 100644 --- a/docs/memory/architecture/workspace.md +++ b/docs/memory/architecture/workspace.md @@ -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). diff --git a/docs/memory/design-system.md b/docs/memory/design-system.md index cfb922a122..9b752933a8 100644 --- a/docs/memory/design-system.md +++ b/docs/memory/design-system.md @@ -255,7 +255,7 @@ 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 @@ -263,7 +263,7 @@ 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 diff --git a/docs/memory/feature-flows/workspace-agents-at-the-centre.md b/docs/memory/feature-flows/workspace-agents-at-the-centre.md index 0dae3e55a9..32f30d8fa9 100644 --- a/docs/memory/feature-flows/workspace-agents-at-the-centre.md +++ b/docs/memory/feature-flows/workspace-agents-at-the-centre.md @@ -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:` 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` | @@ -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 diff --git a/docs/memory/feature-flows/workspace-chat-tabs-and-titles.md b/docs/memory/feature-flows/workspace-chat-tabs-and-titles.md index b5d55be432..72d08da967 100644 --- a/docs/memory/feature-flows/workspace-chat-tabs-and-titles.md +++ b/docs/memory/feature-flows/workspace-chat-tabs-and-titles.md @@ -22,7 +22,7 @@ PortalConversation.vue
[agent picker] [title, renameable] [+ New chat ⌘J] [★] [voice] [files] [the agent's numbers] ← the shell mounts it 🔖 Main | New chat | Q3 invoi… | Onboardi… | 3 more ▾ - └─ OverflowTabs, dense + fixed-width (160px each) + └─ OverflowTabs, dense + fixed-width (192px each) ⚠ Workspace chat titles aren't being generated … [Dismiss] └─ the shell mounts it; platform admins only (#2579) @@ -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 @@ -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. @@ -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 diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 3656a5e574..833a1d5c43 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -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. @@ -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. diff --git a/src/backend/client_portal/db.py b/src/backend/client_portal/db.py index b399d2a6f4..a51a065e54 100644 --- a/src/backend/client_portal/db.py +++ b/src/backend/client_portal/db.py @@ -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 diff --git a/src/backend/client_portal/router.py b/src/backend/client_portal/router.py index c46709e868..7717894cae 100644 --- a/src/backend/client_portal/router.py +++ b/src/backend/client_portal/router.py @@ -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), diff --git a/src/backend/client_portal/service.py b/src/backend/client_portal/service.py index 9aa3298fc1..7d15045643 100644 --- a/src/backend/client_portal/service.py +++ b/src/backend/client_portal/service.py @@ -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 diff --git a/src/frontend/e2e/workspace-chat-tabs.spec.js b/src/frontend/e2e/workspace-chat-tabs.spec.js index 4b45217d43..13a160fecc 100644 --- a/src/frontend/e2e/workspace-chat-tabs.spec.js +++ b/src/frontend/e2e/workspace-chat-tabs.spec.js @@ -7,8 +7,8 @@ import { agentExists, missingAgentReason } from './helpers/agent-probe.js' * The AC names fixed-width tabs, and NO node-env pin can execute that. vitest * runs `environment: 'node'` with no layout engine and this project has no * component-mount harness, so `portalChatTabsAndTitles.spec.js` can prove the - * class string is on the element and nothing more — that a `w-40` tab actually - * renders 160px wide, that a long title actually ellipsises, and that the strip + * class string is on the element and nothing more — that a `w-48` tab actually + * renders 192px wide, that a long title actually ellipsises, and that the strip * actually repacks, are measurements. * * Learning #1500 is why this is not optional: the last structural change to a @@ -42,8 +42,8 @@ import { agentExists, missingAgentReason } from './helpers/agent-probe.js' const TEST_AGENT = process.env.PORTAL_TEST_AGENT || 'testfix' -// Must equal `FIXED_TAB_WIDTH` in components/OverflowTabs.vue ('w-40'). -const TAB_WIDTH = 160 +// Must equal `FIXED_TAB_WIDTH` in components/OverflowTabs.vue ('w-48'). +const TAB_WIDTH = 192 // The strip's own box, plus every visible tab button inside it. Located // through the strip's `data-testid` and the button role rather than through @@ -124,7 +124,7 @@ test.describe('Workspace chat tabs', () => { for (const tab of m.tabs) { expect(tab.w, `tab "${tab.label}" is one fixed width`).toBe(TAB_WIDTH) } - // Uniform means Main too — a four-character label costs the same 160px as + // Uniform means Main too — a four-character label costs the same 192px as // a sentence. That is the trade the operator's ruling buys, and asserting // it here is what stops someone "optimising" Main back to intrinsic width. const main = m.tabs.find((t) => t.label === 'Main') diff --git a/src/frontend/e2e/workspace-close-chat.spec.js b/src/frontend/e2e/workspace-close-chat.spec.js new file mode 100644 index 0000000000..2422d687cc --- /dev/null +++ b/src/frontend/e2e/workspace-close-chat.spec.js @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test' + +/** + * ent#841 — close a chat with the × on its tab: ARCHIVE, never delete. + * Plus the tab's unread count (the sidebar row's blue badge, on the tab). + * + * HERMETIC and `@smoke`: the session list and the archive endpoints are a + * small stateful fake, so a close really changes what the next read returns. + */ + +const API = '**/api/enterprise/client-portal' +const AGENT = 'e2e-close' +const json = (body) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + +const row = (id, title, at, over = {}) => ({ + id, agent_name: AGENT, title, created_at: at, last_message_at: at, message_count: 2, archived_at: null, ...over, +}) + +async function mockWorkspace(page, { unread = {} } = {}) { + const sessions = [ + row('main', null, '2026-09-04T10:00:00Z', { is_main: true }), + row('c1', 'Alpha chat', '2026-09-03T10:00:00Z'), + row('c2', 'Beta chat', '2026-09-02T10:00:00Z'), + ] + const calls = [] + await page.route(`${API}/my-agents*`, (r) => r.fulfill(json({ + client_email: 'e2e@example.com', + agents: [{ name: AGENT, display_label: 'Close agent', description: 'e2e fixture', availability: 'ready', playbooks: [] }], + }))) + await page.route(`${API}/sessions*`, (r) => r.fulfill(json({ sessions }))) + await page.route(`${API}/agents/${AGENT}/sessions/*/archive`, (r) => { + const id = r.request().url().split('/sessions/')[1].split('/')[0] + const s = sessions.find((x) => x.id === id) + calls.push(`${r.request().method()} ${id}`) + s.archived_at = r.request().method() === 'PUT' ? '2026-10-08T10:00:00Z' : null + return r.fulfill(json({ ...s })) + }) + await page.route(`${API}/agents/${AGENT}/sessions*`, (r) => r.fulfill(json({ sessions }))) + await page.route(`${API}/chat-state/**`, (r) => r.fulfill({ status: 204 })) + await page.route(`${API}/chat-state*`, (r) => r.fulfill(json({ + chats: Object.entries(unread).map(([id, n]) => ({ kind: 'thread', id, starred: false, unread: n })), + }))) + await page.route(`${API}/agents/*/history*`, (r) => r.fulfill(json({ + session_id: 'c1', messages: [{ id: 'm1', role: 'assistant', content: 'Hello.' }], + }))) + await page.route('**/api/rooms', (r) => r.fulfill(json({ rooms: [] }))) + return { sessions, calls } +} + +const strip = (page) => page.locator('[data-testid="portal-chat-tabs"] nav').first() +const tabNamed = (page, name) => strip(page).locator('button:not([data-tab-close])', { hasText: name }) + +test.describe('Workspace — close a chat from its tab (ent#841)', () => { + test.beforeEach(async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }) }) + + test('@smoke × archives the open chat, moves to its neighbour, and Undo brings it back', async ({ page }) => { + const fake = await mockWorkspace(page) + await page.goto('/workspace/c/c1') + await expect(tabNamed(page, 'Alpha chat')).toBeVisible() + + // Main has no ×; the others do, labelled for what they do. + await expect(strip(page).locator('[data-tab-close="main"]')).toHaveCount(0) + const x = strip(page).locator('[data-tab-close="c1"]') + await expect(x).toHaveAttribute('aria-label', 'Archive chat') + await x.click() + + await expect(tabNamed(page, 'Alpha chat')).toHaveCount(0) + await expect(page).toHaveURL(/\/workspace\/c\/c2$/) // the tab to the right + expect(fake.calls).toEqual(['PUT c1']) + // Nothing deleted: it is listed under Archived. + await page.getByTestId('sidebar-archived-toggle').click() + await expect(page.getByTestId('sidebar-archived')).toContainText('Alpha chat') + + // Undo restores it as an active chat and returns to it. + await page.getByTestId('portal-undo-toast-undo').click() + await expect(page).toHaveURL(/\/workspace\/c\/c1$/) + await expect(tabNamed(page, 'Alpha chat')).toBeVisible() + await expect(page.getByTestId('sidebar-archived')).toHaveCount(0) + expect(fake.calls).toEqual(['PUT c1', 'DELETE c1']) + }) + + test('@smoke opening a chat from Archived reopens it', async ({ page }) => { + const fake = await mockWorkspace(page) + fake.sessions[2].archived_at = '2026-10-01T10:00:00Z' + await page.goto('/workspace/c/c1') + await expect(tabNamed(page, 'Beta chat')).toHaveCount(0) + await page.getByTestId('sidebar-archived-toggle').click() + await page.getByTestId('sidebar-archived').getByText('Beta chat').click() + await expect(page).toHaveURL(/\/workspace\/c\/c2$/) + await expect(tabNamed(page, 'Beta chat')).toBeVisible() + expect(fake.calls).toEqual(['DELETE c2']) + }) + + test('@smoke a background chat with new messages shows the blue count on its tab, without resizing it', async ({ page }) => { + await mockWorkspace(page, { unread: { c2: 3 } }) + await page.goto('/workspace/c/c1') + const beta = tabNamed(page, 'Beta chat') + await expect(beta).toBeVisible() + await expect(beta).toContainText('3') + await expect(beta).toHaveAttribute('aria-label', 'Beta chat, 3 unread') + const badge = beta.locator('span.rounded-full').first() + await expect(badge).toHaveClass(/bg-action-primary-700/) + // Fixed-width strip: the badged tab is exactly as wide as its sibling. + const [a, b] = await Promise.all([tabNamed(page, 'Alpha chat').boundingBox(), beta.boundingBox()]) + expect(Math.round(b.width)).toBe(Math.round(a.width)) + // The open chat never carries one. + await expect(tabNamed(page, 'Alpha chat').locator('span.rounded-full')).toHaveCount(0) + }) +}) diff --git a/src/frontend/e2e/workspace-star-first-click.spec.js b/src/frontend/e2e/workspace-star-first-click.spec.js new file mode 100644 index 0000000000..100b18500a --- /dev/null +++ b/src/frontend/e2e/workspace-star-first-click.spec.js @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/test' + +/** + * abilityai/trinity#3356 — the FIRST click on a chat's star must stick. + * + * HERMETIC and `@smoke`. The defect was a race, so the spec builds the race on + * purpose: a `GET /chat-state` is held open (issued by the 20 s poll, through + * Playwright's clock) while the star is clicked, and released after the PUT + * has landed — carrying the PRE-click state, exactly as a real in-flight read + * does. Before the fix that stale answer overwrote the star: the server had + * it, the screen did not. + */ + +const API = '**/api/enterprise/client-portal' +const json = (body) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + +async function mockWorkspace(page, { sessions }) { + const stars = new Set() + const ctl = { stars, hold: null } + await page.route(`${API}/my-agents*`, (r) => r.fulfill(json({ + client_email: 'e2e@example.com', + agents: [{ name: 'e2e-star', display_label: 'Star agent', description: 'e2e fixture', availability: 'ready', playbooks: [] }], + }))) + await page.route(`${API}/sessions*`, (r) => r.fulfill(json({ sessions }))) + await page.route(`${API}/agents/*/sessions*`, (r) => r.fulfill(json({ sessions }))) + await page.route(`${API}/chat-state/**`, (r) => { + const u = r.request().url() + if (u.endsWith('/star')) { + const id = decodeURIComponent(u.split('/chat-state/thread/')[1].split('/')[0]) + if (r.request().method() === 'PUT') stars.add(id); else stars.delete(id) + } + return r.fulfill({ status: 204 }) + }) + await page.route(`${API}/chat-state*`, async (r) => { + // The answer is computed when the request ARRIVES — the server's state at + // read time — and only then, optionally, held. + const body = json({ chats: [...stars].map((id) => ({ kind: 'thread', id, starred: true, unread: 0 })) }) + if (ctl.hold) { const h = ctl.hold; ctl.hold = null; h.seen(); await h.release } + return r.fulfill(body) + }) + await page.route(`${API}/agents/*/history*`, (r) => r.fulfill(json({ + session_id: sessions[0].id, messages: [{ id: 'm1', role: 'assistant', content: 'Hello.' }], + }))) + await page.route('**/api/rooms', (r) => r.fulfill(json({ rooms: [] }))) + return ctl +} + +// Hold the NEXT chat-state read open; `issued` resolves once it has left. +// `release()` lets it answer and resolves only after the page has APPLIED the +// answer (response received + a frame) — asserting before that would pass on +// the broken code too, because the star is still drawn until the stale read lands. +function holdNextRead(ctl, page) { + let release, seen + const issued = new Promise((res) => { seen = res }) + ctl.hold = { release: new Promise((res) => { release = res }), seen } + return { + issued, + release: async () => { + const answered = page.waitForResponse((r) => r.url().includes('/chat-state') && r.request().method() === 'GET') + release() + await answered + await page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)))) + }, + } +} + +const row = (id, title, over = {}) => ({ + id, agent_name: 'e2e-star', title, created_at: '2026-09-03T10:00:00Z', + last_message_at: '2026-09-03T10:00:00Z', message_count: 2, ...over, +}) + +test.describe('Workspace star — first click sticks (#3356)', () => { + test.beforeEach(async ({ page }) => { + await page.clock.install() + await page.setViewportSize({ width: 1280, height: 800 }) + }) + + test('@smoke header star survives a chat-state read already in flight', async ({ page }) => { + const ctl = await mockWorkspace(page, { sessions: [row('s1', 'First chat')] }) + await page.goto('/workspace/c/s1') + await expect(page.locator('main textarea').first()).toBeVisible() + + const read = holdNextRead(ctl, page) + await page.clock.fastForward(21000) // the 20 s poll issues its read + await read.issued + await page.locator('main button[aria-label="Star this chat"]').click() + await expect.poll(() => [...ctl.stars]).toEqual(['s1']) + await read.release() // the stale, pre-click answer lands + + await expect(page.locator('main button[aria-label="Unstar this chat"]')).toHaveCount(1) + await expect(page.locator('aside button[aria-label="Unstar this chat"]')).toHaveCount(1) + // The chat's tab says it is starred too. + await expect(page.locator('[data-testid="portal-chat-tabs"] nav [data-tab-star]:visible')).toHaveCount(1) + // …and it is saved, not only shown. + await page.reload() + await expect(page.locator('main button[aria-label="Unstar this chat"]')).toHaveCount(1) + }) + + test('@smoke sidebar row star survives the same race', async ({ page }) => { + const ctl = await mockWorkspace(page, { sessions: [row('s1', 'First chat'), row('s2', 'Second chat', { last_message_at: '2026-09-02T10:00:00Z' })] }) + await page.goto('/workspace/c/s1') + await expect(page.locator('main textarea').first()).toBeVisible() + + const read = holdNextRead(ctl, page) + await page.clock.fastForward(21000) + await read.issued + const second = page.locator('aside [role="button"]', { hasText: 'Second chat' }).first() + await second.hover() + await second.getByRole('button', { name: 'Star this chat' }).click() + await expect.poll(() => [...ctl.stars]).toEqual(['s2']) + await read.release() + + await expect(page.locator('aside [role="button"]', { hasText: 'Second chat' }).first() + .getByRole('button', { name: 'Unstar this chat' })).toBeVisible() + }) + + test('@smoke starring an unused Main lists it under Starred', async ({ page }) => { + await mockWorkspace(page, { sessions: [row('main1', null, { is_main: true, last_message_at: null, message_count: 0 })] }) + await page.goto('/workspace/c/main1') + await expect(page.locator('main textarea').first()).toBeVisible() + await expect(page.locator('aside button[aria-label="Unstar this chat"]')).toHaveCount(0) + + await page.locator('main button[aria-label="Star this chat"]').click() + await expect(page.locator('aside button[aria-label="Unstar this chat"]')).toHaveCount(1) + }) +}) diff --git a/src/frontend/raw-color-baseline.json b/src/frontend/raw-color-baseline.json index 35fca34e08..b85772778f 100644 --- a/src/frontend/raw-color-baseline.json +++ b/src/frontend/raw-color-baseline.json @@ -18,6 +18,7 @@ "also_absorbed": "19 files had IMPROVED below their old entries; those are now recorded at their real values, so the stale-ceiling check has something true to compare against.", "paying_down": "This freeze is a starting line, not an endorsement. Lowering an entry needs no ceremony — regenerate and the guard keeps biting at the new level.", "2638": "SubscriptionsPanel.vue raw_gray 165 -> 173 (+8): the API-key fallback toggle row. Not paid down because there is nothing to pay it down TO — `gray` is the sanctioned chrome family and tailwind.config.js defines no semantic neutral to replace it with (status-*/state-*/brand-*/accent-*/action-* are all meaning-carrying). The 8 classes are the label, the help text and the toggle's off-state, copied verbatim from the three identical toggles immediately above it; inventing a one-off semantic token for the fourth would make it the odd one out and leave the other three unconverted. Re-frozen in its own commit as the guard's own message prescribes, and scoped to this ONE entry rather than regenerated, so no unrelated drift is absorbed with it.", + "_ent841_note": "2026-10-08, ent#841: components/OverflowTabs.vue raw_gray 26 -> 28 (+2) — the hover ground of the new tab close button (gray-100 light / gray-750 dark, the contract's chrome fills). The × itself takes the tab's own ink (text-current) and its disabled state is an opacity on the glyph, so the first draft's 6 gray classes were trimmed to these 2; there is no semantic neutral to move a hover ground to. Scoped to this ONE entry rather than regenerated, so no unrelated drift is absorbed with it. raw_nongray stays 0.", "_2718_note": "2026-09-12, #2718: hardcoded_colors 456 -> 447 in 8 files, none of it a code change. The scanner counted a `#` followed by three or six hex digits anywhere in a