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
Binary file added docs/design/evidence/task-269-after-1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-269-after-390-zh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-269-after-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-269-before-1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-269-before-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const OPAQUE_PLACEHOLDERS = [
'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os',
// common.aboutPage receives a page title, currently Latin, not a count.
'adapter', 'title',
'holder',
];

// The exception runs the safe way: a name that is usually opaque and is a number
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,7 @@
},
"board": {
"title": "Board",
"claimUntil": "{{holder}} holds this until {{time}}",
"backToChat": "Back to chat",
"newTask": "New task",
"taskCount_one": "{{count}} task",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,7 @@
},
"board": {
"title": "看板",
"claimUntil": "{{holder}} 已认领 · 至{{time}}",
"backToChat": "返回聊天",
"newTask": "新建任务",
"taskCount_other": "{{count}}个任务",
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/v2/__tests__/V2PodBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,51 @@ describe('V2PodBoard', () => {
expect(taskReads).toBe(2);
});

test('shows the claim holder and local expiry, then hides the lease without a reload', async () => {
const NativeDateTimeFormat = Intl.DateTimeFormat;
const dateTimeFormatSpy = jest.spyOn(Intl, 'DateTimeFormat').mockImplementation((locale, options) => new NativeDateTimeFormat(locale, {
timeZone: 'America/Los_Angeles',
...options,
}));
const now = new Date('2026-10-15T22:00:00.000Z');
const expiresAt = new Date(now.getTime() + 60_000).toISOString();
const task = {
taskId: 'TASK-269', title: 'Show the lease', status: 'claimed',
assignee: 'someone-else', claimedBy: 'forge-id', claimExpiresAt: expiresAt, updates: [],
};
const holderFromUpdate = {
taskId: 'TASK-270', title: 'Renew the lease', status: 'claimed',
claimedBy: 'kai-id', claimExpiresAt: expiresAt,
updates: [{ text: 'Lease renewed', author: 'Kai', authorId: 'kai-id' }],
};
jest.useFakeTimers({ now });
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: [task, holderFromUpdate] } });
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' }],
} });
return Promise.resolve({ data: {} });
});

try {
renderBoard();
await act(async () => { await Promise.resolve(); });
expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('America/Los_Angeles');
expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument();
expect(screen.getByText('Kai holds this until 15:01')).toBeInTheDocument();

act(() => { jest.advanceTimersByTime(59_999); });
expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument();
act(() => { jest.advanceTimersByTime(1); });
expect(screen.queryByText(/holds this until/)).not.toBeInTheDocument();
expect(axios.get.mock.calls.filter(([url]) => url.startsWith('/api/v1/tasks/'))).toHaveLength(1);
} finally {
jest.useRealTimers();
dateTimeFormatSpy.mockRestore();
}
});

test('renders four canonical columns and places alias statuses in them', async () => {
renderBoard();

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,13 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(title).toContain('overflow: hidden');
});

test('a board claim lease reads as secondary status rather than a blue mention', () => {
expect(podBoard).toContain('className="v2-board__card-claim-lease"');
const lease = ruleBody(v2, '.v2-board__card-claim-lease');
expect(lease).toContain('font-size: 12px');
expect(lease).toContain('color: var(--v2-text-secondary)');
});

test('craft-pass board action chips keep their 32px target and 12px type', () => {
// These compact controls are repeated on every board card and in the
// detail dialog. A declaration on the shared selector keeps both paths
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/v2/components/V2PodBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ interface BoardTask {
title: string;
status: string;
assignee?: string | null;
claimedBy?: string | null;
claimExpiresAt?: string | null;
notes?: string | null;
prUrl?: string | null;
updatedAt?: string;
updates?: Array<{ text: string; author: string; createdAt?: string }>;
updates?: Array<{ text: string; author: string; authorId?: string | null; createdAt?: string }>;
}

interface PodMember {
Expand Down Expand Up @@ -134,6 +136,7 @@ const V2PodBoard: React.FC = () => {
const connectionRef = useRef({ podId: null as string | null, wasConnected: false, hasConnected: false });

const [tasks, setTasks] = useState<BoardTask[]>([]);
const [claimClock, setClaimClock] = useState(() => Date.now());
const [podName, setPodName] = useState<string>('');
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
Expand All @@ -144,6 +147,28 @@ const V2PodBoard: React.FC = () => {
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [podMembers, setPodMembers] = useState<PodMember[]>([]);

// Re-render at the next lease boundary even if no socket event arrives.
// A lapsed claim must stop looking held while the board stays open.
useEffect(() => {
const nextExpiry = tasks.reduce((soonest, task) => {
if (task.status !== 'claimed' || !task.claimedBy || !task.claimExpiresAt) return soonest;
const expiry = Date.parse(task.claimExpiresAt);
return Number.isFinite(expiry) && expiry > claimClock ? Math.min(soonest, expiry) : soonest;
}, Infinity);
if (!Number.isFinite(nextExpiry)) return undefined;
const timer = window.setTimeout(() => setClaimClock(Date.now()), Math.min(Math.max(0, nextExpiry - Date.now()), 2_147_483_647));
return () => window.clearTimeout(timer);
}, [tasks, claimClock]);

const claimHolderName = useCallback((task: BoardTask): string => {
const member = podMembers.find((item) => memberId(item) === 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.
const holderUpdate = task.updates?.slice().reverse().find((update) => update.authorId === task.claimedBy);
return holderUpdate?.author || task.claimedBy || '';
}, [podMembers]);
const [focusRead, setFocusRead] = useState<FocusRead | null>(null);
const [focusLoading, setFocusLoading] = useState(true);
const [focusError, setFocusError] = useState(false);
Expand Down Expand Up @@ -607,6 +632,15 @@ const V2PodBoard: React.FC = () => {
{task.assignee && (
<span className="v2-board__card-assignee">@{task.assignee}</span>
)}
{task.status === 'claimed' && task.claimedBy && task.claimExpiresAt
&& Date.parse(task.claimExpiresAt) > claimClock && (
<span className="v2-board__card-claim-lease">
{t('board.claimUntil', {
holder: claimHolderName(task),
time: new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(task.claimExpiresAt)),
})}
</span>
)}
</button>
<div className="v2-board__card-actions">
{col.moves.map((move) => (
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -8686,6 +8686,12 @@ body.modern-ui.v2-canvas {
font-weight: 500;
}

.v2-board__card-claim-lease {
font-size: 12px;
color: var(--v2-text-secondary);
font-weight: 500;
}

.v2-board__card-actions {
display: flex;
gap: 6px;
Expand Down
Loading