(
style={{
display: 'flex',
flexDirection: 'column',
- background: c.bg.surface,
- border: `1px solid ${c.border.subtle}`,
+ // Drop toolbar card chrome when popover is open so we don't double-card; popover supplies its own surface.
+ background: historyOpen ? 'transparent' : c.bg.surface,
+ border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.xl}px`,
- boxShadow: c.shadow.lg,
+ boxShadow: historyOpen ? 'none' : c.shadow.lg,
padding: isExpanded ? '6px' : '5px',
userSelect: 'none' as const,
- overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
- width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined,
+ overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
+ // historyOpen: width owned by the inline history list; leave undefined so framer-motion measures intrinsic size.
+ width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
}}
>
{inputOpen ? (
- // data-onboarding-scope="dock" lets the AC's per-agent-selector
- // resolver prefer this chat input (the new-agent dock that
- // appears after clicking +) over any existing agent-card's
- // chat input. Without this, AC would route to the most
- // recently-spawned agent-card, which is usually the wrong
- // target on step 5/6 (where the "new agent" is the dock draft).
+ // data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
(
/>
) : historyOpen ? (
-
-
-
- setHistoryQuery(e.target.value)}
- placeholder="Search past chats..."
- sx={{
- flex: 1,
- fontSize: '0.85rem',
- color: c.text.primary,
- fontFamily: c.font.sans,
- '& input::placeholder': { color: c.text.ghost, opacity: 1 },
- }}
- />
- {historySearch.loading && historySearch.results.length === 0 && (
-
- )}
-
-
- {historySearch.results.length === 0 && !historySearch.loading ? (
-
-
+ // Past-chat search list. Fixed-size bordered surface (matches the
+ // toolbar popover footprint) with a search input + scrollable
+ // results; clicking a row resumes that chat.
+
+
+
+
+ setHistoryQuery(e.target.value)}
+ placeholder="Search past chats..."
+ sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, fontFamily: c.font.sans, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
+ />
+
+
+ {historySearch.results.length === 0 && !historySearch.loading && (
+
{historyQuery ? 'No matching chats' : 'No chat history yet'}
-
- ) : (
- <>
- {historySearch.results.map((entry) => (
- handleHistorySelect(entry.id)}
- sx={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: 1.5,
- px: 1.5,
- py: 0.9,
- cursor: 'pointer',
- transition: 'background-color 0.1s',
- '&:hover': { bgcolor: c.bg.elevated },
- }}
- >
-
- {entry.name}
-
-
- {formatRelativeTime(entry.closed_at)}
-
-
- ))}
- {historySearch.loading && historySearch.results.length > 0 && (
-
-
-
- )}
- >
- )}
+ )}
+ {historySearch.results.map((entry) => (
+ handleHistorySelect(entry.id)}
+ sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}
+ >
+
+ {entry.name}
+
+
+ {formatRelativeTime(entry.closed_at)}
+
+
+ ))}
+
-
+
) : viewPickerOpen ? (
@@ -684,7 +654,7 @@ const DashboardToolbar = React.forwardRef(
}),
}}
>
-
+
@@ -859,6 +829,7 @@ const DashboardToolbar = React.forwardRef
(
)}
+ >
);
},
);
diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
index fccebf8c..78b43b6b 100644
--- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
+++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
@@ -34,10 +34,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
-
-// ---------------------------------------------------------------------------
-// Helper components & functions (unchanged)
-// ---------------------------------------------------------------------------
+import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
@@ -74,18 +71,7 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi
return null;
};
-function fmtSeconds(seconds: number): string {
- if (seconds < 60) return `${seconds}s`;
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
- const hours = Math.floor(minutes / 60);
- return `${hours}h ${minutes % 60}m`;
-}
-
-// Self-ticking elapsed-time renderer. Owns its own 1Hz interval so only
-// this leaf re-renders per second while a session is active; the rest
-// of AgentCard stays put. Memoized on `status` + `messages` so it
-// doesn't re-tick after the session goes terminal.
+/** Self-ticking elapsed-time leaf; owns its 1Hz interval so AgentCard doesn't re-render every second. */
const ElapsedTimer: React.FC<{
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>;
status: string;
@@ -99,80 +85,6 @@ const ElapsedTimer: React.FC<{
return <>{fmtSeconds(getAgentWorkTime(messages, status).last)}>;
});
-function getAgentWorkTime(
- messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>,
- status: string,
-): { total: number; last: number } {
- // True wall-clock duration: how long the user actually waited, from
- // their prompt to the LAST assistant/system message of that turn.
- // Covers thinking + every tool call + assistant text generation +
- // any subagent/MCP work , anything that consumed user attention.
- //
- // This is intentionally NOT the sum of `thinking.elapsed_ms` (which
- // would cover only reasoning time and miss tool execution). The
- // thinking pill in the chat already exposes reasoning-only as a
- // distinct signal; the header timer's job is to answer "how long
- // did this take?" which is a different question.
- //
- // For each user message we find the LAST adjacent assistant/system
- // message before the next user message , that's the turn boundary.
- // If the turn is still in flight (last user message has no assistant
- // reply yet AND session is running/waiting), extrapolate to now so
- // the timer ticks live.
- //
- // Hidden messages (auto-continuation prompts from MCPActivate, etc.)
- // are skipped , they're system-internal turns the user didn't see
- // and shouldn't be billed for.
- const visible = messages.filter((m) => !m.hidden);
- let totalMs = 0;
- let lastMs = 0;
- for (let i = 0; i < visible.length; i++) {
- const msg = visible[i];
- if (msg.role !== 'user') continue;
-
- // Find the bounds of this turn: from this user message to just
- // before the next user message (or end of array).
- let nextUserIdx = visible.length;
- for (let k = i + 1; k < visible.length; k++) {
- if (visible[k].role === 'user') {
- nextUserIdx = k;
- break;
- }
- }
-
- // Last assistant/system message before the next user message =
- // turn end. Walk backwards from nextUserIdx to find it.
- let turnEndMs: number | null = null;
- for (let k = nextUserIdx - 1; k > i; k--) {
- const r = visible[k].role;
- if (r === 'assistant' || r === 'system') {
- turnEndMs = new Date(visible[k].timestamp).getTime();
- break;
- }
- }
-
- if (turnEndMs == null) {
- // No assistant reply yet for this turn. If the session is
- // actively working, extrapolate to now so the header ticks.
- // Otherwise (terminal session, no reply): contribute 0.
- if (status === 'running' || status === 'waiting_approval') {
- turnEndMs = Date.now();
- } else {
- continue;
- }
- }
-
- const dur = Math.max(0, turnEndMs - new Date(msg.timestamp).getTime());
- totalMs += dur;
- lastMs = dur;
- }
-
- return {
- total: Math.max(0, Math.round(totalMs / 1000)),
- last: Math.max(0, Math.round(lastMs / 1000)),
- };
-}
-
function summarizeToolInput(toolName: string, toolInput: Record