diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index b825c537c..895e3715e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1605,10 +1605,9 @@
"defaultPriority": "default"
},
"owner": {
- "left": "{{holder}} · {{minutes}}m left",
- "assigned": "{{assignee}} · not claimed",
- "expired": "lease expired · {{holder}}",
- "claimed": "{{holder}} · claimed"
+ "leaseShort": "{{minutes}}m",
+ "expiredShort": "expired",
+ "claimedShort": "claimed"
},
"filter": {
"label": "Filters",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 047e6c131..ccaf9cc50 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1597,10 +1597,9 @@
"defaultPriority": "默认"
},
"owner": {
- "left": "{{holder}} · 剩余{{minutes}}分钟",
- "assigned": "{{assignee}} · 尚未认领",
- "expired": "认领已到期 · {{holder}}",
- "claimed": "{{holder}} · 已认领"
+ "leaseShort": "{{minutes}}分钟",
+ "expiredShort": "已到期",
+ "claimedShort": "已认领"
},
"filter": {
"label": "筛选",
diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx
index 7902e1aa5..c076e36fe 100644
--- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx
+++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx
@@ -159,9 +159,10 @@ describe('V2PodBoard', () => {
claimedBy: 'kai-id', claimExpiresAt: expiresAt,
updates: [{ text: 'Lease renewed', author: 'Kai', authorId: 'kai-id' }],
};
+ const assignedOnly = { taskId: 'TASK-271', title: 'Assigned only', status: 'pending', assignee: 'lily-shen', updates: [] };
jest.useFakeTimers({ now });
axios.get.mockImplementation((url) => {
- if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: [task, holderFromUpdate] } });
+ if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: [task, holderFromUpdate, assignedOnly] } });
if (url.endsWith('/focus')) return Promise.resolve({ data: FOCUS });
if (url.startsWith('/api/pods/')) return Promise.resolve({ data: {
name: 'My Workspace', members: [{ _id: 'forge-id', username: 'forge', displayName: 'Forge' }],
@@ -173,15 +174,19 @@ describe('V2PodBoard', () => {
renderBoard();
await act(async () => { await Promise.resolve(); });
expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('America/Los_Angeles');
- expect(screen.getByTitle('TASK-269')).toHaveTextContent('Forge · 1m left');
- expect(screen.getByTitle('TASK-270')).toHaveTextContent('Kai · 1m left');
+ // The card line is the timer and the claimant's avatar (named by its title); no holder text.
+ expect(screen.getByTitle('TASK-269')).toHaveTextContent('1m');
+ expect(screen.getByTitle('TASK-269')).not.toHaveTextContent('Forge');
+ expect(screen.getByTitle('TASK-270')).toHaveTextContent('1m');
+ expect(screen.getByTitle('Forge')).toBeInTheDocument();
+ // An assigned task nobody has claimed shows no owner line at all.
+ expect(screen.getByTitle('TASK-271')).not.toHaveTextContent(/lily-shen|not claimed/);
act(() => { jest.advanceTimersByTime(59_999); });
- expect(screen.getByTitle('TASK-269')).toHaveTextContent('Forge · 1m left');
+ expect(screen.getByTitle('TASK-269')).toHaveTextContent('1m');
act(() => { jest.advanceTimersByTime(1); });
- expect(screen.queryByText(/1m left/)).not.toBeInTheDocument();
- expect(screen.getByText('lease expired · Forge')).toBeInTheDocument();
- expect(screen.getByText('lease expired · Kai')).toBeInTheDocument();
+ expect(screen.getByTitle('TASK-269')).toHaveTextContent('expired');
+ expect(screen.getByTitle('TASK-270')).toHaveTextContent('expired');
expect(axios.get.mock.calls.filter(([url]) => url.startsWith('/api/v1/tasks/'))).toHaveLength(1);
} finally {
jest.useRealTimers();
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 240664c74..ec56883a3 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -5139,9 +5139,8 @@ describe('task priority and lease signals (TASK-443)', () => {
expect(ruleBody(css, '.v2-board__card--blocked')).toContain('box-shadow: inset 3px 0 0 var(--v2-status-blocked-edge)');
expect(ruleBody(css, '.v2-board__col--blocked .v2-board__col-name')).toContain('color: var(--v2-status-blocked)');
});
- test('live, unclaimed and expired ownership retain distinct dots', () => {
+ test('live and expired ownership retain distinct dots', () => {
expect(ruleBody(css, '.v2-board__owner-dot--live')).toContain('background: var(--v2-status-live)');
- expect(ruleBody(css, '.v2-board__owner-dot--not-claimed')).toContain('border: 1px dashed var(--v2-status-pending)');
const expired = ruleBody(css, '.v2-board__owner-dot--expired');
expect(expired).toContain('border: 1px solid var(--v2-status-idle)');
expect(expired).toContain('background: none');
diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx
index 03230e452..b2f675cb0 100644
--- a/frontend/src/v2/components/V2PodBoard.tsx
+++ b/frontend/src/v2/components/V2PodBoard.tsx
@@ -14,7 +14,7 @@
// behaviorally interchangeable while v1 winds down.
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
-import { Trans, useTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SearchIcon from '@mui/icons-material/Search';
@@ -250,7 +250,7 @@ const V2PodBoard: React.FC = () => {
}, [api, podId, detailSaving, t]);
const claimHolderName = useCallback((task: BoardTask): string => {
- const member = podMembers.find((item) => memberId(item) === task.claimedBy);
+ const member = podMembers.find((item) => memberId(item) === task.claimedBy || item.username === task.claimedBy);
if (member) return memberLabel(member);
// The claim route records the claimant's User id and resolved author on
// the update. Match identity rather than the wording of the update text.
@@ -267,17 +267,24 @@ const V2PodBoard: React.FC = () => {
const timeLabel = (expiry: number) => new Intl.DateTimeFormat(undefined, {
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).format(new Date(expiry));
+ // The frame's card line is the lease timer and the claimant's avatar, nothing else:
+ // an assigned-but-unclaimed task shows no owner text.
const ownerLine = (task: BoardTask) => {
if (['done', 'completed', 'complete'].includes(task.status)) return null;
const lease = leaseFor(task);
- if (!lease.claimed && !task.assignee) return null;
- const state = lease.expired ? 'expired' : lease.claimed ? 'live' : 'not-claimed';
- const text = lease.expired ? t('board.owner.expired', { holder: claimHolderName(task) })
- : lease.claimed ? (lease.knownExpiry
- ? }} />
- : t('board.owner.claimed', { holder: claimHolderName(task) }))
- : t('board.owner.assigned', { assignee: task.assignee });
- return {text};
+ if (!lease.claimed) return null;
+ const holder = claimHolderName(task);
+ const member = podMembers.find((item) => memberId(item) === task.claimedBy || item.username === task.claimedBy);
+ const state = lease.expired ? 'expired' : 'live';
+ return (
+
+
+
+ {lease.expired ? t('board.owner.expiredShort') : lease.knownExpiry ? t('board.owner.leaseShort', { minutes: lease.minutes }) : t('board.owner.claimedShort')}
+
+
+
+ );
};
const memberAvatar = (member: PodMember, name: string) => (
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 4fa910a6b..012e44a1a 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -13822,7 +13822,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor
.v2-board__owner-line { display: flex; align-items: center; gap: 6px; font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-secondary); }
.v2-board__owner-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.v2-board__owner-dot--live { background: var(--v2-status-live); }
-.v2-board__owner-dot--not-claimed { border: 1px dashed var(--v2-status-pending); }
.v2-board__owner-dot--expired { border: 1px solid var(--v2-status-idle); background: none; }
.v2-board__card--done { border-color: var(--v2-border-soft); }
.v2-board__card--done .v2-board__card-title { color: var(--v2-text-secondary); }
@@ -13860,6 +13859,7 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor
.v2-board__watcher-list { display: flex; flex-wrap: wrap; gap: 6px; }
.v2-board__watcher { display: inline-flex; align-items: center; gap: 4px; min-height: 28px; max-width: 100%; padding: 0 8px 0 4px; border: 1px solid var(--v2-border-soft); border-radius: 4px; font-size: 12px; overflow-wrap: anywhere; }
.v2-board .v2-board__member-avatar { width: 20px; height: 20px; min-width: 20px; border-radius: 4px; }
+.v2-board .v2-board__card-avatar { width: 20px; height: 20px; min-width: 20px; border-radius: 6px; }
.v2-board__watcher-hint, .v2-board__field small { margin: 8px 0 0; font-size: 12px; line-height: 16px; color: var(--v2-text-muted); }
.v2-board__create-row { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; }
.v2-board__field input, .v2-board__field select { min-height: 40px; border-radius: 4px; }
@@ -13983,8 +13983,10 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor
.v2-board__card-main { display: grid; gap: 4px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; min-width: 0; color: inherit; font: inherit; }
.v2-board__card-kicker > :not(.v2-board__owner-line):not(.v2-board__card-spacer) { flex: none; }
.v2-board__card-kicker { display: flex; align-items: center; gap: 6px; min-width: 0; font: 500 11px/16px var(--v2-font-mono); }
+/* The frame fixes the id row at 16px; its 20px avatar overflows it instead of growing the card. */
+.v2-board__card-kicker, .v2-board__card-kicker .v2-board__owner-line { height: 16px; }
.v2-board__card-spacer { flex: 1 1 auto; }
-.v2-board__card-kicker .v2-board__owner-line { min-width: 0; flex: 0 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-tertiary); }
+.v2-board__card-kicker .v2-board__owner-line { min-width: 0; flex: 0 1 auto; overflow: visible; white-space: nowrap; font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-tertiary); }
.v2-board__card-title {
font: 500 13px/18px var(--v2-font); display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;