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
20 changes: 4 additions & 16 deletions frontend/src/i18n/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,10 @@ describe('i18n configuration', () => {
expect(i18n.t(key, { lng: 'en' })).toBe(expected);
});

it.each([
[0, '1 · 0 agents', '1 member, 0 agents'],
[1, '1 · 1 agent', '1 member, 1 agent'],
[2, '1 · 2 agents', '1 member, 2 agents'],
])('pluralizes the compact pod header at %i agents', (count, visible, accessible) => {
const members = i18n.t('podChat.header.members', { lng: 'en', count: 1 });
const agents = i18n.t('podChat.header.agents', { lng: 'en', count });
expect(i18n.t('podChat.header.compactMeta', { lng: 'en', members: 1, agents })).toBe(visible);
expect(i18n.t('podChat.header.compactMetaAccessible', { lng: 'en', members, agents })).toBe(accessible);
});

it('keeps the Chinese compact count while naming it for assistive tech', () => {
const members = i18n.t('podChat.header.members', { lng: 'zh-CN', count: 1 });
const agents = i18n.t('podChat.header.agents', { lng: 'zh-CN', count: 1 });
expect(i18n.t('podChat.header.compactMeta', { lng: 'zh-CN', members: 1, agents })).toBe('1 · 1个智能体');
expect(i18n.t('podChat.header.compactMetaAccessible', { lng: 'zh-CN', members, agents })).toBe('1名成员,1个智能体');
it('pluralizes the member count the compact pod header shows', () => {
expect(i18n.t('podChat.header.members', { lng: 'en', count: 1 })).toBe('1 member');
expect(i18n.t('podChat.header.members', { lng: 'en', count: 2 })).toBe('2 members');
expect(i18n.t('podChat.header.members', { lng: 'zh-CN', count: 1 })).toBe('1名成员');
});

it('uses the singular agent count in the Your Team header', () => {
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -648,9 +648,7 @@
"agents_one": "{{count}} agent",
"agents_other": "{{count}} agents",
"boardOpen": "board {{count}} open",
"backToPods": "Back to pods",
"compactMeta": "{{members}} · {{agents}}",
"compactMetaAccessible": "{{members}}, {{agents}}"
"backToPods": "Back to pods"
},
"team": {
"view": "View pod team",
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -647,9 +647,7 @@
"agents_one": "{{count}}个智能体",
"agents_other": "{{count}}个智能体",
"boardOpen": "看板{{count}}项待办",
"backToPods": "返回 Pod 列表",
"compactMeta": "{{members}} · {{agents}}",
"compactMetaAccessible": "{{members}},{{agents}}"
"backToPods": "返回 Pod 列表"
},
"team": {
"view": "查看 Pod 团队",
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ test('"Run it here" exposure is counted on first paint, once, and a pick counts
fireEvent.click(screen.getByTestId('byo-mode-hosted'));
expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
});
test('the compact hosted form counts its submit as a click, and the compact BYO form counts no offer', async () => {
test('the compact hosted form counts its submit as a click', async () => {
window.history.pushState({}, '', '/v2/agents/byo?mode=hosted');
renderPage();
await waitFor(() => expect(document.querySelector('[data-testid="byo-hosted-compact"] .v2-byo__submit')).not.toBeNull());
Expand All @@ -216,3 +216,15 @@ test('the compact hosted form counts its submit as a click, and the compact BYO
fireEvent.click(document.querySelector('[data-testid="byo-hosted-compact"] .v2-byo__submit'));
expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }, { step: 'hosted-offer-clicked' }]);
});
test('the compact BYO form counts no offer, and Other ways counts the exposure once the form is left', async () => {
window.history.pushState({}, '', '/v2/agents/byo?from=card&name=sam-claude');
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-card-other-ways')).toBeInTheDocument());
// The instance's hosting answer must land before the no-offer check means anything.
await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/hosted/availability'), expect.anything()));
for (let i = 0; i < 5; i += 1) await act(async () => {});
expect(offerEvents()).toEqual([]);
fireEvent.click(screen.getByTestId('byo-card-other-ways'));
await waitFor(() => expect(offerEvents()).toEqual([{ step: 'hosted-offer-shown' }]));
window.history.pushState({}, '', '/');
});
3 changes: 1 addition & 2 deletions frontend/src/v2/__tests__/V2Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,7 @@ describe('V2Composer send button', () => {
const meta = header.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)');
expect(meta).toHaveTextContent('2 members');
expect(meta).toHaveTextContent('1 agent');
expect(header.querySelector('.v2-pod-header__meta--compact')).toHaveTextContent('2 · 1 agent');
expect(view.getByRole('group', { name: '2 members, 1 agent' })).toBeInTheDocument();
expect(header.querySelector('.v2-pod-header__meta--compact')).toHaveTextContent('2 members');
expect(view.container.querySelector('.v2-thread__working')).toBeNull();
});

Expand Down
9 changes: 5 additions & 4 deletions frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,16 @@ describe('V2Thread teaching empty states', () => {
expect(document.querySelector('.v2-teammate')).toBeNull();
});

test('compact header shows singular agents and names both counts for assistive tech', () => {
test('compact header shows the member count; the agent count stays in the desktop meta', () => {
const detail = makeDetail({
agents: [{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' }],
});
renderChat(detail);

const compact = screen.getByRole('group', { name: '1 member, 1 agent' });
expect(compact).toHaveTextContent('1 · 1 agent');
expect(compact.querySelector('[aria-hidden="true"]')).toHaveTextContent('1 · 1 agent');
const compact = document.querySelector('.v2-pod-header__meta--compact');
expect(compact).toHaveTextContent('1 member');
expect(compact).not.toHaveTextContent('agent');
expect(document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)')).toHaveTextContent('1 agent');
});

test('multi-member regular pods use the same teaching state', () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/__tests__/v2-copy-budget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('page head copy budget', () => {
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('.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; flex: 0 0 auto; white-space: nowrap; }');
expect(css).toContain('box-shadow: var(--v2-focus-ring);');
});
});
6 changes: 5 additions & 1 deletion frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(compactMeta).toBeGreaterThan(-1);
expect(ruleBody(v2, '.v2-pod-header__meta')).toContain('display: flex');
expect(compactMeta).toBeGreaterThan(baseMeta);
// TASK-647 item 3: the phone line keeps the board link, with a 44px hit
// area drawn as a pseudo-element so the 28px meta row does not grow.
expect(ruleBody(v2, 'button.v2-pod-header__board--compact::before')).toContain('inset: -14px -10px');
expect(ruleBody(v2, '.v2-thread__title-line > h1')).toContain('max-width: calc(100% - 36px)');
expect(v2).not.toContain('.v2-thread__working');
});

Expand Down Expand Up @@ -2884,7 +2888,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// row under Scout's message, in the slot the delivery cue and the
// no-agent row share, so they land on the message text column too.
expect(threadMessages).toContain('className="v2-chat__starts"');
expect(threadMessages).toContain('className="v2-chat__start"');
expect(threadMessages).toContain('v2-chat__start${start.action === WELCOME_HOSTED_ACTION ? \' v2-chat__start--primary\' : \'\'}');
// The 44px phone minimum must come AFTER the 32px base in the sheet: at
// equal specificity the later rule wins, and a base rule placed after
// the media block would silently render 32 on a phone.
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1326,8 +1326,9 @@ const V2AgentBYO: React.FC = () => {
)}
{nameLookupError && mode === 'byo' && <div className="v2-byo__error">{t('agentByo.errors.nameLookupFailed')} <button type="button" className="v2-byo__link" onClick={() => setNameLookupRetry((n) => n + 1)}>{t('agentByo.actions.retry')}</button></div>}
{mode === null ? (
// Signal §3 not yet: a dashed slot where the act will be, until a
// place to run it is chosen.
// Signal §3 not yet: a muted line where the act will be, until a
// place to run it is chosen. Plain text, not a box, so it can't
// read as a dead button (UX Lead, TASK-647 item 1).
<div className="v2-byo__unchosen" data-testid="byo-mode-unchosen">
{t('agentByo.actions.chooseFirst')}
</div>
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1802,12 +1802,18 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
{pod.description && <V2Hint label={t('podChat.header.aboutPod')}>{pod.description}</V2Hint>}
</div>
</div>
<span
className="v2-pod-header__meta v2-pod-header__meta--compact"
role="group"
aria-label={t('podChat.header.compactMetaAccessible', { members: memberCountText, agents: agentCountText })}
>
<span aria-hidden="true">{t('podChat.header.compactMeta', { members: humanMemberCount, agents: agentCountText })}</span>
{/* The phone line carries the board link and the member count, the
same two facts the desktop meta leads with (TASK-647 item 3). */}
<span className="v2-pod-header__meta v2-pod-header__meta--compact">
{headerMeta.boardOpen !== null && (
<>
<button type="button" className="v2-pod-header__board v2-pod-header__board--compact" onClick={() => navigate(`/v2/pods/${pod._id}/board`)}>
{t('podChat.header.boardOpen', { count: headerMeta.boardOpen })}
</button>
<span className="v2-pod-header__sep" aria-hidden="true">·</span>
</>
)}
<span>{memberCountText}</span>
</span>
<span className="v2-pod-header__meta">
{metaParts.map((part, index) => (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/components/V2ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
<React.Fragment key={start.id}>
<button
type="button"
className="v2-chat__start"
className={`v2-chat__start${start.action === WELCOME_HOSTED_ACTION ? ' v2-chat__start--primary' : ''}`}
disabled={startsDisabled || starting}
aria-busy={starting || undefined}
onClick={() => onStart(start, String(message.id))}
Expand Down
55 changes: 47 additions & 8 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,19 @@ body.modern-ui.v2-canvas {
cursor: default;
}

/* TASK-647 item 4: the run-hosted start is the screen's one filled action.
Keyed on the action in the component, not on its position. */
.v2-root button.v2-chat__start.v2-chat__start--primary {
background: var(--v2-ink);
border-color: var(--v2-ink);
color: var(--v2-on-ink);
font-weight: 600;
}
.v2-root button.v2-chat__start.v2-chat__start--primary:hover:not(:disabled) {
background: var(--v2-ink-hover);
border-color: var(--v2-ink-hover);
}

/* A line that belongs to the start above it: the hosted start's beta limits,
or why it could not start. Same slot and size as the delivery hint. */
.v2-chat__start-note {
Expand Down Expand Up @@ -2180,7 +2193,16 @@ body.modern-ui.v2-canvas {
text-decoration: none;
}

.v2-root a.v2-first-run__setup:hover,
.v2-root a.v2-first-run__setup {
/* Bordered, not ink: the first-run card keeps one ink action (TASK-647 item 4). */
background: var(--v2-surface);
border: 1px solid var(--v2-border);
color: var(--v2-text-primary);
}
.v2-root a.v2-first-run__setup:hover {
background: var(--v2-surface-hover);
border-color: var(--v2-border-strong);
}
.v2-root button.v2-first-run__hello:hover:not(:disabled) {
background: var(--v2-ink-hover);
color: var(--v2-on-ink);
Expand Down Expand Up @@ -6536,14 +6558,15 @@ body.modern-ui.v2-canvas {
color: var(--v2-text-primary);
}
/* Signal §3 not yet: where the act will be, until a place to run it is
chosen. Sized like the submit button it stands in for. */
chosen. A muted hint line, not a box: a dashed slot read as a dead button
(TASK-647 item 1). */
.v2-byo__unchosen {
align-self: flex-start;
padding: 10px 18px;
border: 1px dashed var(--v2-border-strong);
border-radius: var(--v2-radius);
padding: 0;
border: 0;
color: var(--v2-text-muted);
font-size: 14px;
font-size: 13px;
line-height: 20px;
}
.v2-root button.v2-byo__link {
/* A text action beside or under a line of copy ("Change", "Other ways to
Expand Down Expand Up @@ -11600,7 +11623,10 @@ body.modern-ui.v2-canvas {

.v2-thread__header-row { align-items: center; gap: 8px; }
.v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; }
.v2-thread__title h1 { font: 600 15px/20px var(--v2-font); }
/* The name takes the free width with a zero basis and truncates, so the
"i" hint keeps its place on the line instead of wrapping under it and
growing the header (TASK-647 item 3). */
.v2-thread__title h1 { font: 600 15px/20px var(--v2-font); flex: 1 1 0; min-width: 0; white-space: nowrap; text-overflow: ellipsis; }

.v2-thread .v2-msg__author,
.v2-thread .v2-msg__author-btn { font-size: 15px; }
Expand Down Expand Up @@ -12074,6 +12100,8 @@ body.modern-ui.v2-canvas {

.v2-root button.v2-pod-header__board:hover { text-decoration: underline; }



.v2-root button.v2-thread__back { display: none; }

@media (max-width: 760px) {
Expand Down Expand Up @@ -12134,7 +12162,18 @@ body.modern-ui.v2-canvas {
align-items: flex-start;
}
.v2-pod-header__meta { display: none; }
.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; }
.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; flex: 0 0 auto; white-space: nowrap; }
/* TASK-647 item 3: the board link keeps a 44px tap target without growing
the 28px meta row (the hit area is a pseudo-element), the name keeps room
for the "i" hint, and the compact meta sits closer to the link. */
.v2-root button.v2-pod-header__board--compact { position: relative; }
.v2-root button.v2-pod-header__board--compact::before {
content: '';
position: absolute;
inset: -14px -10px;
}
.v2-thread__title-line > h1 { max-width: calc(100% - 36px); }
.v2-pod-header__meta--compact { gap: 4px; }

.v2-root button.v2-thread__back {
display: inline-grid;
Expand Down
Loading