From d9cc5c62c20365b69194d78c927d15d89bd39096 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 24 Jun 2026 16:48:48 +0300 Subject: [PATCH] feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat" tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless surface as a first-class user-selectable mode rather than dead code. - AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry). New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the Session surface is unavailable and hides the toggle. The toggle swaps SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single 'chat' id; `?tab=session` aliases to 'chat' (hinting session mode). - Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId) forces legacy ChatPanel (which owns resume) via a transient, non-persisted routeForcedMode — without rewriting the user's saved preference. - No backend change (session_tab_enabled already exists). MobileAdmin unaffected: its openChat is a self-contained mobile chat overlay, not an AgentDetail deep-link, so there is nothing to repoint. - docs: architecture Session Tab block + requirements §5.8 note. Related to #1112 Co-Authored-By: Claude Opus 4.8 --- docs/memory/architecture.md | 4 +- docs/memory/requirements.md | 8 ++ src/frontend/src/views/AgentDetail.vue | 115 +++++++++++++++++++------ 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 97ae86c2..7a0dd812 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -430,7 +430,9 @@ Bounded sequential task execution against one agent. Runner is an in-process `as ### Session Tab -`--resume`-default chat surface alongside the existing Chat tab: each turn reattaches via `claude --print --resume `, preserving tool-result memory, mid-skill state, and reasoning state across turns. Strictly parallel to `chat_sessions`/`chat_messages` — no FK, no shared state; separate router (`routers/sessions.py`), store (`stores/sessions.js`), component (`SessionPanel.vue`). `cached_claude_session_id` is the load-bearing field. +`--resume`-default chat surface: each turn reattaches via `claude --print --resume `, preserving tool-result memory, mid-skill state, and reasoning state across turns. Strictly parallel to `chat_sessions`/`chat_messages` — no FK, no shared state; separate router (`routers/sessions.py`), store (`stores/sessions.js`), component (`SessionPanel.vue`). `cached_claude_session_id` is the load-bearing field. + +**Unified Chat tab (#1112):** Agent Detail shows a single **Chat** tab (no separate Session tab) carrying a **Session-mode toggle**, default ON. ON renders `SessionPanel.vue` (`--resume` continuity); OFF renders the legacy stateless `ChatPanel.vue`. The toggle swaps the surface in-place (`v-if` on `effectiveChatMode`); the choice persists per-user in `localStorage['trinity.chatMode']`. Session mode is available only when `sessionsStore.sessionTabEnabled` AND the runtime has `--resume` (not Codex) — otherwise the toggle is hidden and the tab falls back to legacy (never zero chat surfaces). Routing: legacy `?tab=session` aliases to the `chat` tab (`TAB_ALIASES`, `AgentDetail.vue`) and hints session mode; ExecutionDetail "continue as chat" (`?tab=chat&resumeSessionId=…`) forces legacy for that landing via a transient, non-persisted `routeForcedMode` so the legacy `ChatPanel` owns the resume without rewriting the saved preference. **Turn semantics** (`POST .../sessions/{id}/message`, synchronous): always passes `persist_session=True` to the agent. Resume-failure fallback: if the cached UUID's JSONL is missing, clear the cache, increment `consecutive_resume_failures`, retry once cold (counter reset on next success). Two Redis gates, both with dynamic TTL = `db.get_execution_timeout(agent) + 30s` capped at 7230s: (1) per-`(agent, claude_uuid)` resume lock `session_lock:{agent}:{uuid}` (async wait, 30s ceiling, 429 on contention) serialises concurrent `--resume` calls to prevent JSONL corruption; keyed `session_lock:cold:{session_id}` for cold turns (#779); (2) per-session in-flight sentinel `session_inflight:{session_id}` drives `turn_in_progress` on the GET endpoint so the UI can reattach on KeepAlive activation (#759). diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index a12cbabe..b898a4e2 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -210,6 +210,14 @@ Trinity is autonomous agent orchestration and infrastructure — sovereign infra - **Default**: ON (`session_tab_enabled` flag flipped to True for GA on 2026-05-04, PR #652) - **Spec**: `docs/planning/SESSION_TAB_2026-04.md` - **Flow**: `docs/memory/feature-flows/session-tab.md` +- **Unified Chat tab (#1112)**: the separate Session tab is collapsed into the single + **Chat** tab, which carries a **Session-mode toggle** (default ON, persisted + per-user in `localStorage['trinity.chatMode']`). ON → `SessionPanel`; OFF → + legacy `ChatPanel`. The toggle is hidden and the tab falls back to legacy when + `session_tab_enabled` is off or the runtime lacks `--resume` (Codex) — never + zero chat surfaces. `?tab=session` aliases to the Chat tab; execution-resume + (`resumeSessionId`) forces legacy for that landing without changing the saved + preference. See architecture → Session Tab. --- diff --git a/src/frontend/src/views/AgentDetail.vue b/src/frontend/src/views/AgentDetail.vue index 4616aea1..0f078491 100644 --- a/src/frontend/src/views/AgentDetail.vue +++ b/src/frontend/src/views/AgentDetail.vue @@ -89,22 +89,52 @@ - -
- -
- - -
- + +
+ +
+ Session mode + +
+ +
+ + +
@@ -306,17 +336,20 @@ const error = ref('') const activeTab = ref('overview') // #1107: Overview is the default landing tab // Tabs reachable via ?tab= deep-link (Timeline / EXEC-023 navigation). // Single source — referenced in onMounted + onActivated (#1107: dedupe + overview). -const DEEP_LINK_TABS = ['overview', 'tasks', 'chat', 'session', 'dashboard', 'logs', 'files', 'schedules', 'credentials', 'skills', 'sharing', 'permissions', 'git', 'folders', 'settings', 'info'] +const DEEP_LINK_TABS = ['overview', 'tasks', 'chat', 'dashboard', 'logs', 'files', 'schedules', 'credentials', 'skills', 'sharing', 'permissions', 'git', 'folders', 'settings', 'info'] // Legacy ?tab= ids that moved/renamed — keep old deep-links working (#1108). -const TAB_ALIASES = { guardrails: 'settings' } +// #1112: the Session tab collapsed into Chat, so ?tab=session resolves to chat +// (the session-mode toggle, not the tab id, selects the surface). +const TAB_ALIASES = { guardrails: 'settings', session: 'chat' } // Resolve a ?tab= value to a live tab id (applying aliases), or null if unknown. function resolveDeepLinkTab(requested) { const resolved = TAB_ALIASES[requested] || requested return DEEP_LINK_TABS.includes(resolved) ? resolved : null } // Tabs that need a full-viewport flex layout (input pinned to bottom). -// Chat + Session both render ChatMessages which depends on flex-1 grow. -const isFullscreenTab = computed(() => activeTab.value === 'chat' || activeTab.value === 'session') +// #1112: single unified Chat tab (both session and legacy modes render +// ChatMessages, which depends on flex-1 grow). +const isFullscreenTab = computed(() => activeTab.value === 'chat') const showResourceModal = ref(false) const showAvatarModal = ref(false) const avatarIdentityPrompt = ref('') @@ -346,6 +379,30 @@ const tokenStats = ref(null) const resumeSessionId = computed(() => route.query.resumeSessionId || null) const resumeExecutionId = computed(() => route.query.executionId || null) +// #1112: Chat-tab session-mode toggle. The unified Chat tab renders SessionPanel +// (--resume continuity) or the legacy ChatPanel (stateless). The user's choice +// persists per-user via localStorage (one preference across agents), default ON. +const CHAT_MODE_KEY = 'trinity.chatMode' +const chatMode = ref(localStorage.getItem(CHAT_MODE_KEY) === 'legacy' ? 'legacy' : 'session') +// Transient routing override (NOT persisted): execution-resume must land on the +// legacy ChatPanel, which owns resumeSessionId — without changing the saved pref. +const routeForcedMode = ref(null) +// Session surface is available only when the platform flag is on AND the runtime +// has --resume machinery (Codex does not, #1187). +const sessionAvailable = computed( + () => sessionsStore.sessionTabEnabled && agent.value?.runtime !== 'codex' +) +const effectiveChatMode = computed(() => { + if (!sessionAvailable.value) return 'legacy' // feature-flag / codex fallback + if (routeForcedMode.value) return routeForcedMode.value + return chatMode.value +}) +function toggleChatMode() { + routeForcedMode.value = null // user intent overrides routing + chatMode.value = effectiveChatMode.value === 'session' ? 'legacy' : 'session' + try { localStorage.setItem(CHAT_MODE_KEY, chatMode.value) } catch (e) { /* ignore */ } +} + // Initialize composables const { notification, showNotification } = useNotification() @@ -651,14 +708,10 @@ const visibleTabs = computed(() => { { id: 'chat', label: 'Chat' } ] - // Session tab — SESSION_TAB_2026-04. Sits between Chat and the rest; - // gated on the platform feature flag so it's invisible until enabled. - // Hidden for runtimes without cached-UUID --resume (Codex, #1187): they lack - // the Session tab's resume machinery, so the backend runs stateless turns and - // the tab would be misleading. Chat (with continuity) stays available. - if (sessionsStore.sessionTabEnabled && agent.value?.runtime !== 'codex') { - tabs.push({ id: 'session', label: 'Session' }) - } + // #1112: the Session tab collapsed into the single Chat tab above. The + // Session surface is now reached via the Chat tab's "Session mode" toggle + // (default ON), gated on the same feature-flag + non-Codex-runtime condition + // (see `sessionAvailable`). No separate tab entry. // Dashboard tab - only show if agent has a dashboard.yaml file (insert after Tasks) if (hasDashboard.value) { @@ -1074,7 +1127,13 @@ onMounted(async () => { if (resolvedTab) { activeTab.value = resolvedTab } + // #1112: a legacy ?tab=session deep-link expresses session-mode intent. + if (route.query.tab === 'session') chatMode.value = 'session' } + // #1112: execution-resume (ExecutionDetail "continue as chat") carries a + // claude_session_id the legacy ChatPanel resumes — force legacy for this + // landing without persisting the change to the user's saved preference. + if (resumeSessionId.value) routeForcedMode.value = 'legacy' }) // onActivated fires when component is shown (after being cached by KeepAlive)