diff --git a/docs/audits/task-322/page-heads-1200.png b/docs/audits/task-322/page-heads-1200.png new file mode 100644 index 0000000000..5e316e57ea Binary files /dev/null and b/docs/audits/task-322/page-heads-1200.png differ diff --git a/docs/audits/task-322/page-heads-390.png b/docs/audits/task-322/page-heads-390.png new file mode 100644 index 0000000000..2904285ba2 Binary files /dev/null and b/docs/audits/task-322/page-heads-390.png differ diff --git a/frontend/design-system/README.md b/frontend/design-system/README.md index c9bc69213a..315ad3ec93 100644 --- a/frontend/design-system/README.md +++ b/frontend/design-system/README.md @@ -179,10 +179,11 @@ entrance and scroll-reveal animation is allowed within these limits: ### Layout rules +- **Page heads** use `V2PageHead`: one title, an optional live count in mono, and actions. Put explanation in `V2Hint` beside the title. The (i) opens on hover, keyboard focus, and tap; at 760px and below its text expands inline. Do not add eyebrow or sentence subtitle rows. Errors and requests for action remain visible. - **Fixed sidebar widths** (rail 76, pods 272, inspector 336) — not fluid. The main pane absorbs all flex. The content pane is the card: 8px gutter top/right/bottom on desktop, flush on phones; when the inspector is open the card meets the inspector gutter. - **Rail collapses labels** at all widths — labels show as tooltips on hover (CSS `::after` with `data-label`). - **Inspector closes** to free width for the main pane; default state is collapsed. -- Header heights cluster at **42px (tabs)**, **34px (chips/buttons)**, **78px (chat header w/ subtitle)**. +- Header heights cluster at **42px (tabs)**, **34px (chips/buttons)**, **78px (chat header)**. - Mobile breakpoints at `1200px / 992px / 900px / 768px`. Below 900px the rail and pods sidebar collapse off-canvas. - **App-shell scroll containment.** `.v2-root` is a fixed-height app shell — `height: 100vh; overflow: hidden` — so the shell chrome never scrolls and each diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index 223da37905..f6f716e765 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -41,7 +41,8 @@ const OPAQUE_PLACEHOLDERS = [ 'name', 'second', 'first', 'handle', 'author', 'affiliations', 'username', 'code', 'label', 'taskId', 'agent', 'machine', 'time', 'section', 'platform', 'projects', 'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os', - 'adapter', + // common.aboutPage receives a page title, currently Latin, not a count. + 'adapter', 'title', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fc8ce9ab23..c3927ab092 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -7,6 +7,7 @@ "close": "Close", "copy": "Copy", "copied": "Copied!", + "aboutPage": "About {{title}}", "language": { "label": "Language" }, @@ -56,6 +57,13 @@ "activity": { "title": "Activity", "subtitle": "what needs you, then what moved", + "about": "About activity", + "head": { + "needsYou_one": "{{count}} needs you", + "needsYou_other": "{{count}} need you", + "moved_one": "{{count}} moved", + "moved_other": "{{count}} moved" + }, "controlsAriaLabel": "Activity controls", "windowAriaLabel": "Activity window", "windows": { @@ -94,6 +102,7 @@ "updatesCount_one": "{{count}} update", "updatesCount_other": "{{count}} updates", "needsYou": { + "about": "About needs you", "eyebrow": "Decision queue", "title": "Needs you", "description": "Direct mentions, approvals, and active decisions that need your call.", @@ -166,6 +175,7 @@ } }, "movedForward": { + "about": "About moved forward", "title": "Moved forward", "description": "grouped by pod · newest first", "empty": "Nothing yet today.", @@ -515,6 +525,7 @@ "none": "No recent heartbeats" }, "header": { + "aboutPod": "About this pod", "agentsWorking_one": "{{count}} agent working", "agentsWorking_other": "{{count}} agents working", "members_one": "{{count}} member", @@ -1343,7 +1354,6 @@ } }, "agentByo": { - "eyebrow": "Your own agent", "title": "Bring your own agent", "description": "Connect the Claude Code or Codex you already use, so it answers you in your pods. One command in Terminal, about 2 minutes.", "form": { @@ -1949,6 +1959,13 @@ } }, "connectors": { + "about": "About connectors", + "counts": { + "connected_one": "{{count}} connected", + "connected_other": "{{count}} connected", + "needsYou_one": "{{count}} needs you", + "needsYou_other": "{{count}} need you" + }, "loading": "Loading connectors…", "loadError": "Could not load connectors.", "createError": "Could not create the connector.", @@ -2096,6 +2113,7 @@ }, "artifacts": { "title": "Artifacts", + "about": "About artifacts", "meta": "every file and page shared in your pods", "kindLabel": "Kind", "podLabel": "Pod", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 10f202b386..dbbdcdf51b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -7,6 +7,7 @@ "close": "关闭", "copy": "复制", "copied": "已复制", + "aboutPage": "关于 {{title}}", "language": { "label": "语言" }, @@ -56,6 +57,13 @@ "activity": { "title": "动态", "subtitle": "需要你处理的事项,以及刚刚推进的工作", + "about": "关于动态", + "head": { + "needsYou_one": "{{count}}项需要你处理", + "needsYou_other": "{{count}}项需要你处理", + "moved_one": "{{count}}项已推进", + "moved_other": "{{count}}项已推进" + }, "controlsAriaLabel": "动态控制项", "windowAriaLabel": "动态时间范围", "windows": { @@ -94,6 +102,7 @@ "updatesCount_one": "{{count}}条更新", "updatesCount_other": "{{count}}条更新", "needsYou": { + "about": "关于需要你处理的事项", "eyebrow": "决策队列", "title": "需要你处理", "description": "这里只显示需要你回应的直接提及、审批和待决事项。", @@ -166,6 +175,7 @@ } }, "movedForward": { + "about": "关于推进的工作", "title": "推进的工作", "description": "按 Pod 分组 · 最新在前", "empty": "今天还没有更新。", @@ -515,6 +525,7 @@ "recent_other": "最近收到{{formattedCount}}次心跳" }, "header": { + "aboutPod": "关于这个 Pod", "agentsWorking_other": "{{count}}个智能体正在工作", "members_one": "{{count}}名成员", "members_other": "{{count}}名成员", @@ -1403,7 +1414,6 @@ } }, "agentByo": { - "eyebrow": "你自己的智能体", "title": "接入你自己的智能体", "description": "接入你已在用的 Claude Code 或 Codex,让它在你的 Pod 里回复你。只需在终端里运行一条命令,约2分钟完成。", "form": { @@ -1941,6 +1951,13 @@ } }, "connectors": { + "about": "关于连接器", + "counts": { + "connected_one": "已连接{{count}}个频道", + "connected_other": "已连接{{count}}个频道", + "needsYou_one": "{{count}}项需要你处理", + "needsYou_other": "{{count}}项需要你处理" + }, "loading": "正在加载连接器…", "loadError": "无法加载连接器。", "createError": "无法创建连接器。", @@ -2088,6 +2105,7 @@ }, "artifacts": { "title": "产物", + "about": "关于产物", "meta": "你所有 Pod 中共享的文件与页面", "kindLabel": "类型", "podLabel": "Pod", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 9aed2483f6..b2b91f6f07 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -154,7 +154,6 @@ const V2UseCaseRedirect: React.FC = () => { const feature = ( title: string, - description: string, children: React.ReactNode, showPodsSidebar = false, showHeader = true, @@ -162,7 +161,6 @@ const feature = ( ) => ( { path="pods/:podId/board" element={feature( 'Board', - 'Pod task board inside the v2 shell.', , false, /* V2PodBoard owns its own header (pod name + back). */ @@ -254,7 +251,6 @@ const V2App: React.FC = () => { path="pods/:podType/:roomId" element={feature( 'Pod Tools', - 'Full pod chat, files, member, and agent tools without leaving v2.', , false, /* ChatRoom owns its own sticky AppBar with title/tabs; @@ -271,7 +267,6 @@ const V2App: React.FC = () => { path="agents" element={feature( 'Your Team', - 'Agents you have hired across your projects.', , false, /* V2YourTeamPage owns its own header — suppress the @@ -281,7 +276,7 @@ const V2App: React.FC = () => { /> )} + element={feature('Manage agents', )} /> { /> , - false, - )} + element={} /> , false, // The Signal artboard owns this page's display-sized head. @@ -309,15 +298,15 @@ const V2App: React.FC = () => { /> , false, false, 'v2-feature--artifacts')} + element={feature('Artifacts', , false, false, 'v2-feature--artifacts')} /> , false, false, 'v2-feature--activity')} + element={feature('Activity', , false, false, 'v2-feature--activity')} /> , false, false)} + element={feature('Settings', , false, false)} /> { /> )} + element={feature('Profile', )} /> , false, )} @@ -344,7 +332,6 @@ const V2App: React.FC = () => { path="admin/users" element={feature( 'User Admin', - 'Review waitlist requests and manage invitation codes.', , false, )} @@ -353,7 +340,6 @@ const V2App: React.FC = () => { path="admin/analytics" element={feature( 'Usage Analytics', - 'Activation funnel, signups, messages, and active-user counts for this instance.', , false, )} @@ -362,7 +348,6 @@ const V2App: React.FC = () => { path="dev/api" element={feature( 'API Dev', - 'Developer API inspection tools inside v2.', , false, )} @@ -371,7 +356,6 @@ const V2App: React.FC = () => { path="dev/pod-context" element={feature( 'Pod Context Dev', - 'Pod context inspection tools inside v2.', , false, )} diff --git a/frontend/src/v2/__tests__/V2Composer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx index fbd52e7558..d42b23a2da 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -93,7 +93,7 @@ describe('V2Composer send button', () => { const header = view.container.querySelector('.v2-pod-header'); expect(header).toBeTruthy(); expect(header.querySelector('h1')).toHaveTextContent('My Workspace'); - expect(header.querySelector('.v2-thread__title p')).toHaveTextContent('Team discussion'); + expect(header.querySelector('.v2-hint__content')).toHaveTextContent('Team discussion'); const meta = header.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); expect(meta).toHaveTextContent('2 members'); expect(meta).toHaveTextContent('1 agent'); diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 3a667367f3..04461a7906 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -986,6 +986,28 @@ describe('V2ConnectorsPage', () => { }); }; + it('counts connected channels in the page head, not catalogue options', async () => { + mockCatalog([ + entry(), + entry({ + installableId: 'slack', label: 'Slack', + installation: { status: 'active', boundPodId: 'p1' }, + integration: liveIntegration(), + }), + ]); + const { container } = renderPage(); + + await waitFor(() => expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')?.textContent).toBe('1 connected')); + }); + + it('omits the connected count when the catalogue has no live channels', async () => { + mockCatalog([entry(), entry({ installableId: 'discord', label: 'Discord' })]); + const { container } = renderPage(); + + await screen.findByRole('button', { name: 'View Telegram' }); + expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')).toBeNull(); + }); + it('TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row', async () => { // The catalogue half reads `InstallableInstallation.errorMessage`, whose // projection-failure writer stores `error.message` verbatim. Same rule as diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx new file mode 100644 index 0000000000..0882d042a7 --- /dev/null +++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import fs from 'fs'; +import path from 'path'; +import V2PageHead from '../components/V2PageHead'; + +const source = (name: string) => fs.readFileSync(path.resolve(__dirname, '..', name), 'utf8'); + +describe('page head copy budget', () => { + test('the shared feature header cannot render an eyebrow or sentence description', () => { + const feature = source('components/V2FeaturePage.tsx'); + const app = source('V2App.tsx'); + expect(feature).not.toMatch(/eyebrow\??:|description\??:|v2-feature__description|v2-feature__eyebrow/); + expect(app).not.toMatch(/description=\{description\}|description: string/); + expect(source('components/V2AgentBYO.tsx')).not.toMatch(/eyebrow=|description=/); + expect(source('components/V2ArtifactsPage.tsx')).not.toContain('v2-artifacts__meta'); + for (const file of ['components/V2ActivityPage.tsx', 'components/V2ConnectorsPage.tsx', 'components/V2Thread.tsx']) { + expect(source(file)).not.toMatch(/v2-activity__subtitle|v2-connectors__header p|

\{pod\.description\}<\/p>/); + } + }); + + test('details open on hover, focus, and tap without replacing the title or count', () => { + render(); + const hint = screen.getByRole('button', { name: 'About this page' }); + const detail = screen.getByText('What needs you, then what moved'); + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(hint.compareDocumentPosition(screen.getByText('3')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(detail.parentElement).not.toHaveClass('v2-hint--open'); + fireEvent.mouseEnter(hint.parentElement!); + expect(detail.parentElement).toHaveClass('v2-hint--open'); + fireEvent.mouseLeave(hint.parentElement!); + expect(detail.parentElement).not.toHaveClass('v2-hint--open'); + fireEvent.focus(hint); + expect(detail.parentElement).toHaveClass('v2-hint--open'); + fireEvent.blur(hint, { relatedTarget: null }); + expect(detail.parentElement).not.toHaveClass('v2-hint--open'); + fireEvent.pointerDown(hint, { pointerType: 'touch' }); + fireEvent.focus(hint); + expect(detail.parentElement).not.toHaveClass('v2-hint--open'); + fireEvent.click(hint); + expect(detail.parentElement).toHaveClass('v2-hint--open'); + fireEvent.click(hint); + expect(detail.parentElement).not.toHaveClass('v2-hint--open'); + }); + + test('phone disclosure keeps its trigger in the title row and uses the shared focus ring', () => { + const css = source('v2.css'); + expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .v2-thread__title-line .v2-hint--open { display: contents; }'); + expect(css).toContain('.v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; }'); + expect(css).toContain('.v2-thread__title-line .v2-hint__content { width: 100%; flex-basis: 100%; order: 1; }'); + expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 11px 0;[^}]*align-items: flex-start;/); + expect(css).toContain('.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; }'); + expect(css).toContain('box-shadow: var(--v2-focus-ring);'); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d83b23409f..495b707759 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -635,7 +635,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2App).toContain("'v2-feature--artifacts'"); expect(v2App).toContain(''); expect(ruleBody(v2, '.v2-artifacts__title')).toContain('32px/1.1 var(--v2-font-display)'); - expect(ruleBody(v2, '.v2-artifacts__meta')).toContain('color: var(--v2-text-muted)'); + expect(ruleBody(v2, '.v2-page-head__meta')).toContain('color: var(--v2-text-muted)'); expect(ruleBody(v2, '.v2-artifacts__seg')).toContain('border: 1px solid var(--v2-border)'); expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).toContain('background: var(--v2-surface-hover)'); expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).not.toContain('var(--v2-ink)'); @@ -2177,7 +2177,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(layoutBlocks('v2-connectors').filter((b) => b.includes('min-height'))).toEqual([]); expect(layoutBlocks('v2-connectors__content').filter((b) => b.includes('flex: 1'))).toEqual([]); expect(layoutBlocks('v2-connectors__rows').filter((b) => b.includes('flex: 1'))).toEqual([]); - expect(ruleBody(v2, '.v2-connectors__header p')).not.toContain('var(--v2-font-mono)'); + expect(ruleBody(v2, '.v2-page-head__meta')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit'); expect(ruleBody(v2, '.v2-connector-row--not-yet .v2-connector-row__glyph')).toContain('color: var(--v2-text-placeholder)'); @@ -2417,7 +2417,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(lastRuleBody(v2, '.v2-feature--activity .v2-feature__legacy')).toContain('max-width: none'); // The page head is display 32 + muted 13 meta (ux-lead 66400 fix 2), not a 52px bar. expect(lastRuleBody(v2, '.v2-activity__title')).toContain('32px/36px var(--v2-font-display)'); - expect(lastRuleBody(v2, '.v2-activity__subtitle')).toContain('color: var(--v2-text-muted)'); + expect(activityPage).toContain('hint={t(\'activity.subtitle\')}'); expect(lastRuleBody(v2, '.v2-activity__header')).toContain('border-bottom: 0'); expect(lastRuleBody(v2, '.v2-activity')).toContain('width: 100%'); expect(lastRuleBody(v2, '.v2-activity__sections')).toContain('760px'); @@ -2438,7 +2438,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-activity__queue-row\.v2-activity__queue-row--decision \.v2-activity__option-choice button \{ width: 100%; min-height: 44px; \}/); // 0-state (66422 miss 1): the dashed panel renders whenever nothing is open, settled cards under it, no kicker. expect(activityPage).toContain('{(queueCount === 0 || visibleQueue.length === 0) && ('); - expect(activityPage).toContain('{queueCount !== 0 &&

{queueCount === null'); + expect(activityPage).toContain('queueCount === null && '); + expect(activityPage).toContain('queueCount !== null && queueCount !== 0 && { return (

-
-
-

{t('activity.title')}

- {t('activity.subtitle')} -
-
+ sum + group.lines.length, 0) })}`} + hint={t('activity.subtitle')} + hintLabel={t('activity.about')} + />
{(['today', '7d'] as ActivityWindow[]).map((value) => ( @@ -1217,9 +1221,10 @@ const V2ActivityPage: React.FC = () => {

{t('activity.needsYou.title')}

{queueCount !== null && queueCount > 0 && {queueCount}} - {queueCount !== 0 &&

{queueCount === null - ? t('activity.needsYou.countUnavailable', { defaultValue: 'Count unavailable' }) - : t(podId === 'all' ? 'activity.needsYou.countDescription' : 'activity.needsYou.scopedCountDescription', { count: queueCount })}

} + {queueCount === null && {t('activity.needsYou.countUnavailable', { defaultValue: 'Count unavailable' })}} + {queueCount !== null && queueCount !== 0 && + {t(podId === 'all' ? 'activity.needsYou.countDescription' : 'activity.needsYou.scopedCountDescription', { count: queueCount })} + }
{queueFailed ? <>

{t('activity.loadFailed')}

@@ -1411,7 +1416,7 @@ const V2ActivityPage: React.FC = () => {

{t('activity.movedForward.title')}

-

{t('activity.movedForward.description')}

+ {t('activity.movedForward.description')}
{movedGroups.length === 0 ?
{t('activity.movedForward.empty')}
:
{movedGroups.map((group) => { diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 91ffc240ad..1c10161508 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -803,10 +803,6 @@ const V2AgentBYO: React.FC = () => { return ( { - if (issued) return undefined; - return showCompactHosted && !hosted ? t('agentByo.mode.onCommonly') : t('agentByo.eyebrow'); - })()} title={(() => { if (showCompactHosted && !hosted) return t('agentByo.compact.title'); // The command page keeps the tool's name (QA BYO walk 3: it reverted to @@ -817,9 +813,7 @@ const V2AgentBYO: React.FC = () => { } return t('agentByo.title'); })()} - // Once the command is shown, the page's lead is behind the person: what - // sits above the copy is only what the paste needs (QA BYO walk 2). - description={(() => { + hint={(() => { if (issued) return undefined; if (showCompactHosted && !hosted) return t('agentByo.compact.lead'); if (showCompactByo) return t('agentByo.compactByo.lead'); diff --git a/frontend/src/v2/components/V2ArtifactsPage.tsx b/frontend/src/v2/components/V2ArtifactsPage.tsx index 528207a95b..76f7d5020e 100644 --- a/frontend/src/v2/components/V2ArtifactsPage.tsx +++ b/frontend/src/v2/components/V2ArtifactsPage.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; import V2Lightbox from './V2Lightbox'; +import V2PageHead from './V2PageHead'; export type ArtifactKind = 'image' | 'page' | 'doc'; export interface ArtifactItem { @@ -160,12 +161,14 @@ const V2ArtifactsPage: React.FC = () => { return (
-
-
-

{t('artifacts.title')}

- {t('artifacts.meta')} -
-
+
{KINDS.map((option) => ( diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index f6275004b9..c5d19bbb12 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -15,6 +15,7 @@ import { ActGlyph, MarkGlyph, MarkName } from '../icons/glyphs'; import V2ConnectorTools, { type HostedMcpManualRevokeNotice } from './V2ConnectorTools'; import { hostedMcpErrorNotice } from './hostedMcpErrorNotice'; import { localizeRelativeTime } from '../utils/localizeRelativeTime'; +import V2PageHead from './V2PageHead'; interface ConnectorGate { enabled?: boolean; @@ -1135,6 +1136,17 @@ const V2ConnectorsPage: React.FC = () => { // A backend type is an identifier, not a customer-facing name. Unknown types // have no Channels row until the catalog provides a label or this map does. const items = allItems.filter((item) => Boolean(itemLabel(item))); + const connectedCount = items.filter((item) => item.connector?.status === 'connected').length; + const needsYouCount = items.filter((item) => ( + item.connector?.status === 'error' + || (item.kind === 'catalog' && item.entry.installation?.status === 'error') + || (item.connector?.status === 'connected' && !connectorPodId(item.connector)) + || Boolean(item.connector?.config?.pendingBind) + )).length; + const headMeta = [ + connectedCount ? t('connectors.counts.connected', { count: connectedCount }) : null, + needsYouCount ? t('connectors.counts.needsYou', { count: needsYouCount }) : null, + ].filter(Boolean).join(' · '); const itemType = (item: ListItem): string => (item.kind === 'catalog' ? item.entry.installableId : item.connector.type); @@ -1639,10 +1651,13 @@ const V2ConnectorsPage: React.FC = () => { return (
-
-

{t('connectors.title', { defaultValue: 'Connectors' })}

-

{t('connectors.description', { defaultValue: 'Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.' })}

-
+ {loading &&
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
} diff --git a/frontend/src/v2/components/V2FeaturePage.tsx b/frontend/src/v2/components/V2FeaturePage.tsx index 52bbeb20da..1c461843f5 100644 --- a/frontend/src/v2/components/V2FeaturePage.tsx +++ b/frontend/src/v2/components/V2FeaturePage.tsx @@ -1,14 +1,15 @@ import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; import V2NavRail from './V2NavRail'; import V2PodsSidebar from './V2PodsSidebar'; import { V2EmbeddedProvider } from '../hooks/useV2Embedded'; +import V2PageHead from './V2PageHead'; interface V2FeaturePageProps { - eyebrow?: string; title: string; - description?: string; + hint?: React.ReactNode; children: React.ReactNode; showPodsSidebar?: boolean; showHeader?: boolean; @@ -288,17 +289,15 @@ const v2FeatureTheme = createTheme({ }); const V2FeaturePage: React.FC = ({ - // No default kicker — "v2" is internal terminology and renders on every - // feature-page header otherwise. Callers can opt in by passing eyebrow. - eyebrow, title, - description, + hint, children, showPodsSidebar = false, showHeader = true, className = '', }) => { const navigate = useNavigate(); + const { t } = useTranslation(); useEffect(() => { try { @@ -329,15 +328,7 @@ const V2FeaturePage: React.FC = ({ {showPodsSidebar && }
- {showHeader && ( -
-
- {eyebrow &&
{eyebrow}
} -

{title}

- {description &&

{description}

} -
-
- )} + {showHeader && }
diff --git a/frontend/src/v2/components/V2Hint.tsx b/frontend/src/v2/components/V2Hint.tsx new file mode 100644 index 0000000000..4ad333bf49 --- /dev/null +++ b/frontend/src/v2/components/V2Hint.tsx @@ -0,0 +1,49 @@ +import React, { useId, useRef, useState } from 'react'; + +interface V2HintProps { + children: React.ReactNode; + label: string; +} + +const V2Hint: React.FC = ({ children, label }) => { + const id = useId(); + const [open, setOpen] = useState(false); + const pointerFocus = useRef(false); + + return ( + { if (!pointerFocus.current) setOpen(true); }} + onMouseLeave={() => { if (!pointerFocus.current) setOpen(false); }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + pointerFocus.current = false; + setOpen(false); + } + }} + > + + {children} + + ); +}; + +export default V2Hint; diff --git a/frontend/src/v2/components/V2PageHead.tsx b/frontend/src/v2/components/V2PageHead.tsx new file mode 100644 index 0000000000..a237b10961 --- /dev/null +++ b/frontend/src/v2/components/V2PageHead.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import V2Hint from './V2Hint'; + +interface V2PageHeadProps { + title: React.ReactNode; + meta?: React.ReactNode; + actions?: React.ReactNode; + hint?: React.ReactNode; + className?: string; + titleClassName?: string; + metaClassName?: string; + hintLabel?: string; +} + +const V2PageHead: React.FC = ({ + title, meta, actions, hint, className = '', titleClassName = '', metaClassName = '', hintLabel = 'About this page', +}) => ( +
+
+

{title}

+ {hint != null && {hint}} + {meta != null && {meta}} +
+ {actions != null &&
{actions}
} +
+); + +export default V2PageHead; diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index d47218fe1e..92c8bd8bd4 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -5,6 +5,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useLocation, useNavigate } from 'react-router-dom'; import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; import V2Avatar from './V2Avatar'; +import V2Hint from './V2Hint'; import V2CatchUpStrip from './V2CatchUpStrip'; import V2Composer from './V2Composer'; import { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard'; @@ -1691,7 +1692,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in

{pod.name}

- {pod.description &&

{pod.description}

} + {pod.description && {pod.description}}
p { - margin: 6px 0 0; - color: var(--v2-text-secondary); - font-size: 14px; - line-height: 1.55; -} .v2-activity__controls, .v2-activity__window, @@ -9716,12 +9769,6 @@ body.modern-ui.v2-canvas { margin-bottom: 8px; } -.v2-activity__section-heading > p { - margin: 0; - color: var(--v2-text-muted); - font-size: var(--v2-fs-meta); - line-height: var(--v2-lh-meta); -} .v2-activity__count { color: var(--v2-text-secondary); @@ -10290,7 +10337,6 @@ body.modern-ui.v2-canvas { touch target on phones. */ .v2-root .v2-activity__compose .v2-activity__compose-picker-menu .v2-activity__compose-picker-option { min-height: 44px; } .v2-activity__section-heading { flex-wrap: wrap; } - .v2-activity__section-heading > p { flex-basis: 100%; } .v2-activity__agent-grid { grid-template-columns: minmax(0, 1fr); } .v2-activity__queue-row { grid-template-columns: 28px minmax(0, 1fr); } @@ -10412,8 +10458,6 @@ body.modern-ui.v2-canvas { .v2-root:lang(zh) .v2-pods__join-notice, .v2-root:lang(zh) .v2-pods__discover-description, .v2-root:lang(zh) .v2-pods__community-copy, -.v2-root:lang(zh) .v2-activity__subtitle, -.v2-root:lang(zh) .v2-activity__section-heading > p, .v2-root:lang(zh) .v2-activity__queue-copy p, .v2-root:lang(zh) .v2-activity__board-row p, .v2-root:lang(zh) .v2-activity__agent-recap, @@ -10454,12 +10498,6 @@ body.modern-ui.v2-canvas { letter-spacing: -0.03em; line-height: 36px; } -.v2-connectors__header p { - margin: 6px 0 0; - color: var(--v2-text-tertiary); - font-size: 14px; - line-height: 20px; -} .v2-connectors__loading { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; } .v2-connectors__content { display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px); gap: 40px; align-items: start; } .v2-connectors__main { display: flex; min-width: 0; min-height: 0; flex-direction: column; } @@ -11279,17 +11317,6 @@ body.modern-ui.v2-canvas { max-width: 100%; } -.v2-thread__title p { - min-width: 0; - margin: 0; - overflow: hidden; - color: var(--v2-text-muted); - font: 400 13px/18px var(--v2-font); - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 1 0; -} - .v2-root button.v2-thread__inspector-toggle { display: inline-grid; width: 28px; @@ -11353,9 +11380,8 @@ body.modern-ui.v2-canvas { } .v2-thread__header-row { align-items: center; gap: 8px; } - .v2-thread__title-line { display: block; } + .v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; } .v2-thread__title h1 { font: 600 15px/20px var(--v2-font); } - .v2-thread__title p { display: none; } .v2-thread .v2-msg__author, .v2-thread .v2-msg__author-btn { font-size: 15px; } @@ -11882,9 +11908,14 @@ body.modern-ui.v2-canvas { .v2-pods-aside:not(.v2-pods-aside--page) { display: none; } .v2-mobile-backdrop { display: none; } - .v2-pod-header .v2-thread__header-row { min-height: 50px; } + .v2-pod-header .v2-thread__header-row { + box-sizing: border-box; + min-height: 50px; + padding: 11px 0; + align-items: flex-start; + } .v2-pod-header__meta { display: none; } - .v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; } + .v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; } .v2-root button.v2-thread__back { display: inline-grid; @@ -12612,9 +12643,7 @@ body.modern-ui.v2-canvas { The composer remains under the explicit destination ruling. */ .v2-activity { position: relative; width: 100%; max-width: none; padding: 0 0 28px; } .v2-activity__header { min-height: 64px; align-items: baseline; padding: 20px 16px 4px; gap: 16px; border-bottom: 0; overflow: visible; } -.v2-activity__bar-title { display: flex; align-items: baseline; gap: 10px; min-width: 0; } .v2-activity__title { font: 700 32px/36px var(--v2-font-display); letter-spacing: -0.03em; white-space: nowrap; } -.v2-activity__subtitle { margin: 0; color: var(--v2-text-muted); font-size: 13px; line-height: 18px; white-space: nowrap; } .v2-activity__controls { position: absolute; top: 22px; right: 16px; gap: 8px; z-index: 2; } .v2-activity__scope { display: flex; flex-wrap: nowrap; gap: 2px; position: relative; padding: 3px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } .v2-activity__scope-menu { position: absolute; top: calc(100% + 4px); right: 0; display: grid; gap: 2px; width: min(300px, 85vw); max-height: 240px; overflow-y: auto; padding: 4px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } @@ -12624,7 +12653,6 @@ body.modern-ui.v2-canvas { .v2-root button.v2-activity__window-button { font: 600 11px/16px var(--v2-font-mono); } .v2-activity__sections { width: min(100%, 760px); margin: 28px auto 0; gap: 34px; } .v2-activity__section-heading { margin-bottom: 10px; } -.v2-activity__section-heading > p { font: 400 11px/16px var(--v2-font-mono); } .v2-activity__queue { gap: 10px; } .v2-activity__queue-row { grid-template-columns: 28px minmax(0, 1fr) auto; padding: 11px; border: 2px solid var(--v2-accent); border-radius: var(--v2-radius); background: var(--v2-surface); } .v2-activity__queue-row:hover { background: var(--v2-surface); } @@ -12711,8 +12739,6 @@ body.modern-ui.v2-canvas { title. Both rows stay in normal reading order, without a clipped title. */ @media (max-width: 1100px) { .v2-activity__controls { position: relative; top: auto; right: auto; display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--v2-border-soft); } - .v2-activity__bar-title { min-width: 0; } - .v2-activity__subtitle { overflow: hidden; text-overflow: ellipsis; } .v2-activity__window { width: auto; flex: 0 0 auto; } .v2-activity__scope { min-width: 0; max-width: 100%; flex-wrap: wrap; } } @@ -12955,9 +12981,7 @@ body.modern-ui.v2-canvas { .v2-feature--artifacts .v2-feature__legacy { max-width: none; } .v2-artifacts { display: flex; flex-direction: column; gap: 20px; width: 100%; padding: 20px 16px 0; } .v2-artifacts__head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } -.v2-artifacts__heading { display: flex; align-items: baseline; gap: 12px; min-width: 0; } .v2-artifacts__title { margin: 0; font: 700 32px/1.1 var(--v2-font-display); letter-spacing: -0.03em; color: var(--v2-text-primary); } -.v2-artifacts__meta { font-size: 13px; color: var(--v2-text-muted); white-space: nowrap; } .v2-artifacts__controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .v2-artifacts__seg { position: relative; display: flex; flex-wrap: nowrap; gap: 2px; padding: 3px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } .v2-root button.v2-artifacts__seg-button { min-height: 30px; max-width: 150px; padding: 0 9px; border: 0; border-radius: calc(var(--v2-radius-sm) - 2px); background: transparent; color: var(--v2-text-secondary); font: 600 11px/16px var(--v2-font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }