From b7379ca90f8515bf3ac2dcfa4e965300d03fc635 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:14:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(chat):=20catch-up=20strip=20=E2=80=94=20th?= =?UTF-8?q?e=20constant=20agents-TL;DR=20above=20the=20transcript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam's ruling (2026-09-01): knowing what the agents are doing required navigating to Activity. The answer now lives where the question is felt: a thin strip pinned above the pod's messages with the latest summary as a one-line snippet, expandable, with Summarize/Refresh riding the existing gated endpoints (GET /api/summaries/pod/:id, POST .../refresh). Advisory by construction: a failed read never blocks the chat, the expanded body scrolls inside the strip, and the snippet ellipsizes on one line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PTfziSyx2DzuwmibZHHY4s --- frontend/src/i18n/locales/en.json | 13 +- frontend/src/i18n/locales/zh-CN.json | 13 +- .../src/v2/__tests__/V2CatchUpStrip.test.tsx | 63 +++++++++ frontend/src/v2/components/V2CatchUpStrip.tsx | 121 ++++++++++++++++++ frontend/src/v2/components/V2PodChat.tsx | 3 + frontend/src/v2/v2.css | 62 +++++++++ 6 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx create mode 100644 frontend/src/v2/components/V2CatchUpStrip.tsx diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c0b1084b7..79a0c3c00 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -440,7 +440,18 @@ "replyFromExpandedThread": "Reply from expanded thread", "replyingInThread": "Replying in thread ·" }, - "leadBadge": "Lead" + "leadBadge": "Lead", + "catchup": { + "title": "Catch up", + "justNow": "just now", + "minutesAgo": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "daysAgo": "{{count}}d ago", + "empty": "No summary yet", + "summarize": "Summarize", + "refresh": "Refresh", + "working": "Summarizing…" + } }, "landing": { "nav": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 9432a9a63..e4efccf2b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -440,7 +440,18 @@ "replyFromExpandedThread": "在已展开的话题中回复", "replyingInThread": "正在话题中回复:" }, - "leadBadge": "负责人" + "leadBadge": "负责人", + "catchup": { + "title": "回顾", + "justNow": "刚刚", + "minutesAgo": "{{count}} 分钟前", + "hoursAgo": "{{count}} 小时前", + "daysAgo": "{{count}} 天前", + "empty": "还没有摘要", + "summarize": "生成摘要", + "refresh": "刷新", + "working": "正在总结…" + } }, "landing": { "nav": { diff --git a/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx b/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx new file mode 100644 index 000000000..c2d1184c4 --- /dev/null +++ b/frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx @@ -0,0 +1,63 @@ +// @ts-nocheck +// The constant agents-TL;DR pinned above the transcript (Sam 2026-09-01). +// Pinned: reads the existing summaries surface, renders a one-line snippet, +// offers Summarize when the pod has none, and a refresh replaces the content. +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import V2CatchUpStrip from '../components/V2CatchUpStrip'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), + post: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const axios = jest.requireMock('axios').default; + +afterEach(() => jest.clearAllMocks()); + +describe('V2CatchUpStrip', () => { + test('renders the latest summary as a one-line snippet with its age', async () => { + axios.get.mockResolvedValueOnce({ + data: { content: 'Otto verified the token split.\nKai shipped the daemon auth.', createdAt: new Date(Date.now() - 5 * 60000).toISOString() }, + }); + render(); + await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument()); + // Snippet is whitespace-collapsed to one line. + expect(screen.getByText('Otto verified the token split. Kai shipped the daemon auth.')).toBeInTheDocument(); + expect(screen.getByText(/5m ago/)).toBeInTheDocument(); + expect(axios.get).toHaveBeenCalledWith('/api/summaries/pod/p1'); + }); + + test('expanding shows the full body; no summary shows the Summarize action', async () => { + axios.get.mockResolvedValueOnce({ data: { content: 'Line one.\nLine two.', createdAt: new Date().toISOString() } }); + render(); + await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Catch up/ })); + expect(screen.getByTestId('catchup-body')).toHaveTextContent('Line one. Line two.'); + }); + + test('a pod with no summary offers Summarize; refresh swaps in the generated summary', async () => { + axios.get.mockResolvedValueOnce({ data: null }); + axios.post.mockResolvedValueOnce({ data: { summary: { content: 'Fresh digest.', createdAt: new Date().toISOString() } } }); + render(); + await waitFor(() => expect(screen.getByText('No summary yet')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Summarize' })); + await waitFor(() => expect(screen.getByTestId('catchup-body')).toHaveTextContent('Fresh digest.')); + expect(axios.post).toHaveBeenCalledWith('/api/summaries/pod/p2/refresh', {}); + }); + + test('a failed summary read never blocks the chat — strip still renders', async () => { + axios.get.mockRejectedValueOnce(new Error('403')); + render(); + await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument()); + expect(screen.getByText('No summary yet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/components/V2CatchUpStrip.tsx b/frontend/src/v2/components/V2CatchUpStrip.tsx new file mode 100644 index 000000000..61b3e96b4 --- /dev/null +++ b/frontend/src/v2/components/V2CatchUpStrip.tsx @@ -0,0 +1,121 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import axios from 'axios'; + +// Sam's 2026-09-01 ruling: a constant TL;DR of what the agents are doing, +// living where the question is actually felt — pinned above the messages of +// the pod being read (Activity stays the cross-pod view). Backed entirely by +// the existing summaries surface: GET /api/summaries/pod/:podId (latest, +// visibility-gated) and POST .../refresh (rate-limited, falls back to a +// non-agent summary when no summarizer seat is installed). + +interface PodSummary { + content?: string; + createdAt?: string; +} + +interface Props { + podId: string; +} + +const relTime = ( + iso: string | undefined, + t: (key: string, opts?: Record) => string, +): string => { + if (!iso) return ''; + const ms = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(ms) || ms < 0) return ''; + const min = Math.floor(ms / 60000); + if (min < 1) return t('podChat.catchup.justNow'); + if (min < 60) return t('podChat.catchup.minutesAgo', { count: min }); + const hr = Math.floor(min / 60); + if (hr < 24) return t('podChat.catchup.hoursAgo', { count: hr }); + return t('podChat.catchup.daysAgo', { count: Math.floor(hr / 24) }); +}; + +const V2CatchUpStrip: React.FC = ({ podId }) => { + const { t } = useTranslation(); + const [summary, setSummary] = useState(null); + const [loaded, setLoaded] = useState(false); + const [expanded, setExpanded] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + let cancelled = false; + setSummary(null); + setLoaded(false); + setExpanded(false); + axios.get(`/api/summaries/pod/${podId}`) + .then((res) => { + if (cancelled) return; + setSummary(res.data && res.data.content ? res.data : null); + setLoaded(true); + }) + .catch(() => { + // Advisory surface: a failed summary read never blocks the chat. + if (!cancelled) setLoaded(true); + }); + return () => { cancelled = true; }; + }, [podId]); + + const handleRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + try { + const res = await axios.post(`/api/summaries/pod/${podId}/refresh`, {}); + const next = res.data?.summary; + if (next && next.content) { + setSummary(next); + setExpanded(true); + } + } catch { + // Rate-limited or failed — keep whatever we have. + } finally { + setRefreshing(false); + } + }, [podId, refreshing]); + + if (!loaded) return null; + + return ( +
+
+ + {!expanded && ( + + {summary?.content + ? summary.content.replace(/\s+/g, ' ').trim() + : t('podChat.catchup.empty')} + + )} + +
+ {expanded && summary?.content && ( +
{summary.content}
+ )} +
+ ); +}; + +export default V2CatchUpStrip; diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index 6daab399f..9f4d29a11 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import V2Avatar from './V2Avatar'; +import V2CatchUpStrip from './V2CatchUpStrip'; import V2MessageBubble from './V2MessageBubble'; import { UseV2PodDetailResult, @@ -1126,6 +1127,8 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, )} + +
{hasMore && (
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 8f3855383..cd5296cd7 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1662,6 +1662,68 @@ body.modern-ui.v2-canvas { } .v2-chat__older-btn:disabled { opacity: 0.6; cursor: default; } +/* Catch-up strip — the constant agents-TL;DR pinned above the transcript + (Sam 2026-09-01). One thin row; the expanded body is bounded so a long + summary scrolls inside the strip, never the pane. */ +.v2-catchup { + border-bottom: 1px solid var(--v2-border); + background: var(--v2-surface); + padding: 6px 20px; + font-size: 13px; +} +.v2-catchup__row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.v2-root button.v2-catchup__toggle { + background: transparent; + border: none; + padding: 0; + font-size: 13px; + font-weight: 650; + color: var(--v2-text-secondary); + cursor: pointer; + white-space: nowrap; +} +.v2-root button.v2-catchup__toggle:hover:not(:disabled) { color: var(--v2-text-primary); } +.v2-root button.v2-catchup__toggle:disabled { cursor: default; } +.v2-catchup__chevron { + display: inline-block; + margin-right: 4px; + transition: transform 0.08s ease; +} +.v2-catchup__chevron--open { transform: rotate(90deg); } +.v2-catchup__time { font-weight: 400; color: var(--v2-text-tertiary); } +.v2-catchup__snippet { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--v2-text-tertiary); +} +.v2-root button.v2-catchup__refresh { + background: transparent; + border: none; + padding: 0; + font-size: 12px; + color: var(--v2-accent); + cursor: pointer; + white-space: nowrap; +} +.v2-root button.v2-catchup__refresh:hover:not(:disabled) { text-decoration: underline; } +.v2-root button.v2-catchup__refresh:disabled { color: var(--v2-text-tertiary); cursor: default; } +.v2-catchup__body { + margin-top: 6px; + max-height: 220px; + overflow-y: auto; + white-space: pre-wrap; + color: var(--v2-text-secondary); + line-height: 1.5; +} + .v2-chat__messages { flex: 1; overflow-y: auto;