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
13 changes: 12 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,18 @@
"replyFromExpandedThread": "在已展开的话题中回复",
"replyingInThread": "正在话题中回复:"
},
"leadBadge": "负责人"
"leadBadge": "负责人",
"catchup": {
"title": "回顾",
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前",
"empty": "还没有摘要",
"summarize": "生成摘要",
"refresh": "刷新",
"working": "正在总结…"
}
},
"landing": {
"nav": {
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<V2CatchUpStrip podId="p1" />);
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(<V2CatchUpStrip podId="p1" />);
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(<V2CatchUpStrip podId="p2" />);
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(<V2CatchUpStrip podId="p3" />);
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
expect(screen.getByText('No summary yet')).toBeInTheDocument();
});
});
121 changes: 121 additions & 0 deletions frontend/src/v2/components/V2CatchUpStrip.tsx
Original file line number Diff line number Diff line change
@@ -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, unknown>) => 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<Props> = ({ podId }) => {
const { t } = useTranslation();
const [summary, setSummary] = useState<PodSummary | null>(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 (
<div className="v2-catchup" data-testid="catchup-strip">
<div className="v2-catchup__row">
<button
type="button"
className="v2-catchup__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
disabled={!summary}
>
<span className={`v2-catchup__chevron${expanded ? ' v2-catchup__chevron--open' : ''}`} aria-hidden="true">▸</span>
{t('podChat.catchup.title')}
{summary?.createdAt && (
<span className="v2-catchup__time"> · {relTime(summary.createdAt, t)}</span>
)}
</button>
{!expanded && (
<span className="v2-catchup__snippet">
{summary?.content
? summary.content.replace(/\s+/g, ' ').trim()
: t('podChat.catchup.empty')}
</span>
)}
<button
type="button"
className="v2-catchup__refresh"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing
? t('podChat.catchup.working')
: summary ? t('podChat.catchup.refresh') : t('podChat.catchup.summarize')}
</button>
</div>
{expanded && summary?.content && (
<div className="v2-catchup__body" data-testid="catchup-body">{summary.content}</div>
)}
</div>
);
};

export default V2CatchUpStrip;
3 changes: 3 additions & 0 deletions frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1126,6 +1127,8 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, firstRunVisible = false,
)}
</header>

<V2CatchUpStrip podId={pod._id} />

<div className="v2-chat__messages" ref={messagesContainerRef}>
{hasMore && (
<div className="v2-chat__older">
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading