chore(runtime): bump runtime lock to 0.0.22

This commit is contained in:
777genius 2026-05-07 17:16:06 +03:00
parent 8caa962dec
commit 9a1b01b2b6
44 changed files with 3191 additions and 185 deletions

View file

@ -0,0 +1,6 @@
- generic [ref=e1]:
- img
- img [ref=e2]
- generic [ref=e12]:
- generic [ref=e13]: Agent Teams AI
- generic [ref=e15]: Get more done by doing less.

BIN
graph-log-preview-smoke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -128,6 +128,7 @@ const SLOT_GEOMETRY = {
const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001;
const FEED_HEADER_BOTTOM_GAP = 4;
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
@ -372,7 +373,7 @@ function buildOwnerFootprint(args: {
const boardBandHeight = Math.max(
activityColumnHeight,
logColumnHeight,
SLOT_GEOMETRY.kanbanBandHeight
SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth })
);
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
@ -1362,9 +1363,10 @@ function buildSlotFrameAtOwnerAnchor(
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
? SLOT_GEOMETRY.boardColumnGap
: 0;
const kanbanBandTopInset = getKanbanBandTopInset(footprint);
const kanbanBandRect = createRect(
logColumnRect.right + feedToKanbanGap,
boardBandRect.top,
boardBandRect.top + kanbanBandTopInset,
footprint.kanbanBandWidth,
footprint.kanbanBandHeight
);
@ -1390,6 +1392,19 @@ function getOwnerAnchorTopOffset(): number {
return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2;
}
function getKanbanBandTopInset(args: {
activityColumnWidth: number;
logColumnWidth: number;
}): number {
if (args.activityColumnWidth <= 0 && args.logColumnWidth <= 0) {
return 0;
}
const feedCardTopInset = ACTIVITY_LANE.headerHeight + FEED_HEADER_BOTTOM_GAP;
const taskPillTopInset = KANBAN_ZONE.headerHeight - TASK_PILL.height / 2;
return Math.max(0, feedCardTopInset - taskPillTopInset);
}
function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] {
const candidates: GraphOwnerSlotAssignment[] = [];
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {

View file

@ -1,27 +1,27 @@
{
"version": "0.0.21",
"sourceRef": "v0.0.21",
"version": "0.0.22",
"sourceRef": "v0.0.22",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.22.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.22.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.22.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.22.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -27,6 +27,7 @@ import type {
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
const NEW_LOG_HIGHLIGHT_MS = 1_000;
interface StableRectLike {
left: number;
@ -75,7 +76,12 @@ function formatRelativeTime(timestamp: string): string {
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
const className = 'size-3.5 shrink-0';
const title = item.title.trim().toLowerCase();
if (item.tone === 'error') {
return <AlertCircle className={`${className} text-rose-300`} />;
}
if (
title.includes('message') ||
title.includes('comment') ||
title === 'send message' ||
title === 'message sent' ||
title === 'add comment' ||
@ -83,9 +89,6 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
) {
return <MessageSquareText className={`${className} text-sky-300`} />;
}
if (item.tone === 'error') {
return <AlertCircle className={`${className} text-rose-300`} />;
}
if (item.kind === 'tool_result') {
return <CheckCircle2 className={`${className} text-emerald-300`} />;
}
@ -106,6 +109,14 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading:
return 'No recent logs';
}
function compactDisplayTitle(item: MemberLogPreviewItem): string {
const title = item.title.trim();
if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) {
return title.slice(0, -' result'.length).trim() || title;
}
return title;
}
function setShellHidden(shell: HTMLDivElement): void {
shell.style.opacity = '0';
shell.style.pointerEvents = 'none';
@ -125,7 +136,12 @@ export const GraphMemberLogPreviewHud = ({
const worldLayerRef = useRef<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const visibleKeyRef = useRef('');
const knownItemIdsByMemberRef = useRef(new Map<string, Set<string>>());
const highlightTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const [visibleMemberNames, setVisibleMemberNames] = useState<string[]>([]);
const [highlightedItemIds, setHighlightedItemIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const { teamData } = useGraphActivityContext(teamName);
const members = teamData?.members ?? [];
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
@ -155,6 +171,69 @@ export const GraphMemberLogPreviewHud = ({
[onOpenMemberProfile]
);
useEffect(() => {
knownItemIdsByMemberRef.current.clear();
setHighlightedItemIds(new Set());
for (const timer of highlightTimersRef.current.values()) {
clearTimeout(timer);
}
highlightTimersRef.current.clear();
}, [teamName]);
useEffect(() => {
return () => {
for (const timer of highlightTimersRef.current.values()) {
clearTimeout(timer);
}
highlightTimersRef.current.clear();
};
}, []);
useEffect(() => {
if (!enabled) return;
const newItemIds: string[] = [];
for (const [memberKey, preview] of previewsByMember) {
const currentIds = new Set(preview.items.map((item) => item.id));
const knownIds = knownItemIdsByMemberRef.current.get(memberKey);
if (knownIds) {
for (const itemId of currentIds) {
if (!knownIds.has(itemId)) {
newItemIds.push(itemId);
}
}
}
knownItemIdsByMemberRef.current.set(memberKey, currentIds);
}
if (newItemIds.length === 0) return;
setHighlightedItemIds((current) => {
const next = new Set(current);
for (const itemId of newItemIds) {
next.add(itemId);
}
return next;
});
for (const itemId of newItemIds) {
const existingTimer = highlightTimersRef.current.get(itemId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
highlightTimersRef.current.delete(itemId);
setHighlightedItemIds((current) => {
if (!current.has(itemId)) return current;
const next = new Set(current);
next.delete(itemId);
return next;
});
}, NEW_LOG_HIGHLIGHT_MS);
highlightTimersRef.current.set(itemId, timer);
}
}, [enabled, previewsByMember]);
useLayoutEffect(() => {
if (!enabled || ownerNodes.length === 0) {
for (const shell of shellRefs.current.values()) {
@ -285,29 +364,57 @@ export const GraphMemberLogPreviewHud = ({
}, [enabled, forwardWheelToGraph, ownerNodes]);
const renderItem = useCallback(
(memberName: string, item: MemberLogPreviewItem) => (
<button
key={item.id}
type="button"
className="block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.52)] px-2.5 py-2 text-left text-[10px] leading-3 text-slate-400 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => openLogs(memberName)}
>
<span
className="mr-1.5 inline-flex size-4 shrink-0 translate-y-0.5 items-center justify-center rounded bg-white/5 align-middle"
aria-hidden="true"
(memberName: string, item: MemberLogPreviewItem) => {
const relativeTime = formatRelativeTime(item.timestamp);
const displayTitle = compactDisplayTitle(item);
const previewText = item.preview || item.sourceLabel || 'Log event';
const titleText = relativeTime
? `${item.title} ${relativeTime} ${previewText}`
: `${item.title} ${previewText}`;
const isHighlighted = highlightedItemIds.has(item.id);
return (
<button
key={item.id}
type="button"
className={[
'block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]',
isHighlighted
? 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
: 'border-white/10 bg-[rgba(8,14,28,0.52)]',
].join(' ')}
title={titleText}
onClick={() => openLogs(memberName)}
>
{itemIcon(item)}
</span>
<span className="align-baseline text-[11px] font-medium leading-4 text-slate-200">
{item.title}
</span>{' '}
<span className="align-baseline text-[9px] leading-4 text-slate-500">
{formatRelativeTime(item.timestamp)}
</span>{' '}
<span className="align-baseline">{item.preview || item.sourceLabel || 'Log event'}</span>
</button>
),
[openLogs]
<span
className="float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5 align-top"
aria-hidden="true"
>
{itemIcon(item)}
</span>
<span
className="inline-flex h-5 items-center align-top"
style={{ position: 'relative', top: '-3px' }}
>
<span className="text-[11px] font-medium leading-none text-slate-200">
{displayTitle}
</span>
{relativeTime ? (
<span className="ml-1 text-[9px] font-normal leading-none text-slate-500">
{relativeTime}
</span>
) : null}
</span>
<span
className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85"
style={{ position: 'relative', top: '-3px' }}
>
{previewText}
</span>
</button>
);
},
[highlightedItemIds, openLogs]
);
if (!enabled || ownerNodes.length === 0) {

View file

@ -62,6 +62,7 @@ export interface CodexLoginStateDto {
status: CodexAccountLoginStatus;
error: string | null;
startedAt: string | null;
authUrl?: string | null;
}
export interface CodexRuntimeContextDto {

View file

@ -692,6 +692,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
status: 'idle',
error: loginState.status === 'failed' ? loginState.error : null,
startedAt: null,
authUrl: null,
};
}

View file

@ -26,6 +26,7 @@ export class CodexLoginSessionManager {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
};
private pendingStartToken: symbol | null = null;
private activeSession: {
@ -71,6 +72,7 @@ export class CodexLoginSessionManager {
status: 'starting',
error: null,
startedAt: new Date().toISOString(),
authUrl: null,
});
try {
@ -135,6 +137,7 @@ export class CodexLoginSessionManager {
status: 'pending',
error: null,
startedAt: this.state.startedAt,
authUrl: authUrl.toString(),
});
await shell.openExternal(authUrl.toString());
@ -158,6 +161,7 @@ export class CodexLoginSessionManager {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
startedAt: this.state.startedAt,
authUrl: this.state.authUrl,
});
throw error;
}
@ -170,6 +174,7 @@ export class CodexLoginSessionManager {
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
this.emitSettled();
return;
@ -180,6 +185,7 @@ export class CodexLoginSessionManager {
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
return;
}
@ -207,6 +213,7 @@ export class CodexLoginSessionManager {
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
this.emitSettled();
}
@ -221,6 +228,7 @@ export class CodexLoginSessionManager {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
});
return;
}
@ -234,6 +242,7 @@ export class CodexLoginSessionManager {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
});
}
@ -255,12 +264,14 @@ export class CodexLoginSessionManager {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
});
} else {
this.setState({
status: 'failed',
error: notification.error ?? 'ChatGPT login failed.',
startedAt: this.state.startedAt,
authUrl: this.state.authUrl,
});
}
@ -281,6 +292,7 @@ export class CodexLoginSessionManager {
status: 'failed',
error: errorMessage,
startedAt: this.state.startedAt,
authUrl: this.state.authUrl,
});
this.emitSettled();
}

View file

@ -53,6 +53,76 @@ describe('memberLogPreviewExtractor', () => {
expect(result.items[1]?.preview).toBe('older answer');
});
it('extracts readable inbound task and comment messages without agent-only blocks', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'assigned',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:00:00.000Z',
content: `New task assigned to you: #01d7462a *Calculator - final build and test command*
<info_for_agent>
Hidden tool protocol that must not be rendered.
</info_for_agent>
Description:
Run final validation.`,
}),
message({
uuid: 'comment',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: `**Comment on task #1dcfefd2** _Calculator - logic smoke checklist_
> Logic smoke check passed.
<info_for_agent>
Reply to this comment using MCP tool task_add_comment.
</info_for_agent>`,
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Comment received',
preview: '#1dcfefd2: Logic smoke check passed.',
});
expect(result.items[1]).toMatchObject({
kind: 'text',
title: 'Task assigned',
preview: '#01d7462a Calculator - final build and test command',
});
expect(JSON.stringify(result.items)).not.toContain('info_for_agent');
expect(JSON.stringify(result.items)).not.toContain('task_add_comment');
});
it('skips meta tool-result user messages for inbound text extraction', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'meta',
type: 'user',
role: 'user',
isMeta: true,
timestamp: '2026-04-01T10:00:00.000Z',
content: 'Internal runtime metadata',
}),
],
});
expect(result.items).toEqual([]);
});
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
const hugeOutput = 'x'.repeat(10_000);
const result = extractMemberLogPreviewItems({
@ -95,7 +165,7 @@ describe('memberLogPreviewExtractor', () => {
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Tool error',
title: 'Bash error',
tone: 'error',
laneId: 'secondary:opencode:alice',
});
@ -166,15 +236,64 @@ describe('memberLogPreviewExtractor', () => {
title: 'Message sent',
preview: 'Message sent to team-lead - #abc done',
});
expect(result.items).toHaveLength(1);
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
});
it('keeps known tool names on structured error payloads', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'send-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-send',
name: 'agent-teams_message_send',
input: {
to: 'team-lead',
summary: '#abc done',
},
},
],
}),
message({
uuid: 'send-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-send',
content: {
success: false,
message: 'Delivery failed',
},
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Send message error',
preview: 'Delivery failed',
tone: 'error',
});
expect(result.items[1]).toMatchObject({
kind: 'tool_use',
title: 'Send message',
preview: 'to team-lead: #abc done',
});
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
});
it('formats task comment result payloads without raw JSON noise', () => {
it('formats orphan comment result payloads without guessing add vs read semantics', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
@ -211,12 +330,119 @@ describe('memberLogPreviewExtractor', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Comment added',
title: 'Comment',
preview: 'Comment by tom on #task-799: Done with UI review',
});
expect(JSON.stringify(result.items)).not.toContain('"comment"');
});
it('uses tool context to name comment add results precisely', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'comment-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-comment',
name: 'mcp__agent-teams__task_add_comment',
input: {
taskId: 'task-799',
text: 'Done with UI review',
},
},
],
}),
message({
uuid: 'comment-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-comment',
content: JSON.stringify({
taskId: 'task-799',
comment: {
id: 'comment-1',
author: 'tom',
text: 'Done with UI review',
},
}),
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Comment added',
preview: 'Comment by tom on #task-799: Done with UI review',
});
expect(result.items).toHaveLength(1);
});
it('distinguishes read-comment results from add-comment results', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'comment-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-comment',
name: 'mcp__agent-teams__task_get_comment',
input: {
taskId: 'task-799',
commentId: '47697aeb',
},
},
],
}),
message({
uuid: 'comment-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-comment',
content: JSON.stringify({
agent_teams_task_get_comment_response: {
taskId: 'task-799',
comment: {
id: '47697aeb-3734-4d5c-ae3e-42fafcbdea0b',
author: 'tom',
text: 'Готово по UI',
},
},
}),
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Comment loaded',
preview: 'Comment by tom on #task-799: Готово по UI',
});
expect(result.items).toHaveLength(1);
expect(JSON.stringify(result.items)).not.toContain('Comment added');
});
it('formats plain board tool results through the paired tool_use context', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
@ -257,6 +483,47 @@ describe('memberLogPreviewExtractor', () => {
preview: 'Completed #abc12345',
toolName: 'mcp__agent-teams__task_complete',
});
expect(result.items).toHaveLength(1);
});
it('keeps board tool input visible when the paired successful result is empty', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'complete-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-complete',
name: 'mcp__agent-teams__task_complete',
input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' },
},
],
}),
message({
uuid: 'complete-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-complete',
content: '',
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Complete task result',
});
expect(result.items[1]).toMatchObject({
kind: 'tool_use',
title: 'Complete task',
@ -284,7 +551,7 @@ describe('memberLogPreviewExtractor', () => {
task: {
id: 'abc12345-0000-0000-0000-000000000000',
displayId: 'abc12345',
title: 'Fix preview alignment',
subject: 'Fix preview alignment',
status: 'in_progress',
owner: 'tom',
},
@ -304,6 +571,182 @@ describe('memberLogPreviewExtractor', () => {
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
});
it('formats common board and cross-team tool previews compactly', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'cross-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-cross',
name: 'agent-teams_cross_team_send',
input: {
toTeam: 'design-team',
summary: 'Need UI review',
text: 'Please review compact logs',
},
},
{
type: 'tool_use',
id: 'tool-link',
name: 'agent-teams_task_link',
input: {
taskId: 'abc12345',
targetId: 'def67890',
relationship: 'blocked-by',
},
},
],
}),
message({
uuid: 'cross-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-cross',
content: 'ok',
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Cross-team message',
preview: 'to design-team: Need UI review',
});
expect(result.items[1]).toMatchObject({
kind: 'tool_use',
title: 'Link tasks',
preview: '#abc12345 blocked-by #def67890',
});
expect(result.items).toHaveLength(2);
});
it('uses concrete names for generic runtime tool results', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'bash-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-bash',
name: 'bash',
input: {
command: 'pnpm test',
},
},
],
}),
message({
uuid: 'bash-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-bash',
content: 'Tests passed',
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Bash result',
preview: 'Tests passed',
});
expect(result.items[1]).toMatchObject({
kind: 'tool_use',
title: 'Bash',
preview: 'pnpm test',
});
});
it('does not label arbitrary message fields as sent messages', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'generic-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-generic',
content: {
message: 'generic tool status',
},
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Tool result',
preview: 'generic tool status',
});
});
it('formats unknown JSON string results without leaking raw JSON syntax', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'generic-json',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-generic',
content: JSON.stringify({
payload: {
nested: true,
},
status: 'stored',
count: 2,
}),
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Tool result',
preview: 'stored',
});
expect(result.items[0]?.preview).not.toContain('{');
});
it('keeps orphan tool results visible because graph preview is diagnostic', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',

View file

@ -19,6 +19,7 @@ export interface MemberLogPreviewParsedMessage {
role?: string;
timestamp: Date | string;
content: string | MemberLogPreviewContentBlock[];
isMeta?: boolean;
toolCalls?: readonly {
id: string;
name: string;
@ -57,6 +58,8 @@ interface Candidate {
timestampMs: number;
order: number;
textTruncated: boolean;
toolUseKey?: string;
supersededByResult?: boolean;
}
const UNKNOWN_TIMESTAMP_MS = 0;
@ -139,6 +142,19 @@ function compactWhitespace(value: string): string {
return stripAngleTags(value).replace(/\s+/g, ' ').trim();
}
function removeHiddenInstructionBlocks(value: string): string {
let result = value;
for (const tag of [
'info_for_agent',
'opencode_runtime_identity',
'opencode_app_message_delivery',
'system-reminder',
]) {
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
}
return result;
}
function looksLikeJsonPayload(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith('{') || trimmed.startsWith('[');
@ -272,24 +288,89 @@ function canonicalToolNameFromWrapperKey(value: string | undefined): string | nu
);
}
function humanizeFallbackToolName(toolName: string): string {
const stripped = canonicalToolName(toolName);
if (!stripped) return 'Tool use';
const compact = stripped.replace(/[_-]+/g, ' ').trim();
if (!compact) return toolName.trim() || 'Tool use';
const lower = compact.toLowerCase();
if (lower === 'bash' || lower === 'shell') return 'Bash';
if (lower === 'read') return 'Read';
if (lower === 'write') return 'Write';
if (lower === 'edit') return 'Edit';
if (lower === 'grep') return 'Grep';
if (lower === 'glob') return 'Glob';
if (lower === 'ls') return 'List files';
return compact
.split(' ')
.map((part) => (part.length > 0 ? `${part[0]?.toUpperCase()}${part.slice(1)}` : part))
.join(' ');
}
function formatToolTitle(toolName: string): string {
const canonical = canonicalToolName(toolName);
if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message';
if (canonical === 'cross_team_send') return 'Cross-team message';
if (canonical === 'runtime_deliver_message') return 'Runtime delivery';
if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task';
if (canonical === 'task_complete') return 'Complete task';
if (canonical === 'task_add_comment') return 'Add comment';
if (canonical === 'task_get_comment') return 'Read comment';
if (canonical === 'task_get') return 'Read task';
if (canonical === 'task_list') return 'List tasks';
if (canonical === 'task_briefing') return 'Task briefing';
if (canonical === 'task_start') return 'Start task';
if (canonical === 'task_set_status') return 'Set status';
if (canonical === 'task_set_owner') return 'Set owner';
if (canonical === 'task_set_clarification') return 'Set clarification';
if (canonical === 'task_attach_file') return 'Attach file';
if (canonical === 'task_attach_comment_file') return 'Attach comment file';
if (canonical === 'task_link') return 'Link tasks';
if (canonical === 'task_unlink') return 'Unlink tasks';
if (canonical === 'task_restore') return 'Restore task';
if (canonical === 'review_request') return 'Request review';
if (canonical === 'review_start') return 'Start review';
if (canonical === 'review_approve') return 'Approve review';
if (canonical === 'review_request_changes') return 'Request changes';
if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in';
if (canonical === 'member_briefing') return 'Member briefing';
if (canonical === 'task_add') return 'Add task';
return toolName.trim() || 'Tool use';
if (canonical === 'task_update') return 'Update task';
if (canonical === 'task_delete') return 'Delete task';
if (canonical === 'process_list') return 'List processes';
return humanizeFallbackToolName(toolName);
}
function formatGenericToolResultTitle(
toolContext: ToolUseContext | undefined,
isError: boolean
): string {
if (!toolContext) {
return isError ? 'Tool error' : 'Tool result';
}
return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`;
}
function buildToolUseKey(input: {
provider: MemberLogStreamProvider;
sourceId: string;
toolUseId: string;
}): string {
return [input.provider, input.sourceId, input.toolUseId.trim()].join(':');
}
function isToolUseSupersededBySuccessResult(toolName: string): boolean {
const canonical = canonicalToolName(toolName);
return (
canonical === 'sendmessage' ||
canonical === 'message_send' ||
canonical === 'cross_team_send' ||
canonical === 'runtime_deliver_message' ||
canonical === 'runtime_bootstrap_checkin' ||
canonical === 'member_briefing' ||
canonical.startsWith('task_') ||
canonical.startsWith('review_')
);
}
function stringField(
@ -326,7 +407,8 @@ function taskRefFromPayload(
}
function shortTaskSummary(task: Record<string, unknown> | undefined): string | null {
const title = stringField(task, 'title') ?? stringField(task, 'name');
const title =
stringField(task, 'title') ?? stringField(task, 'subject') ?? stringField(task, 'name');
const status = stringField(task, 'status');
const owner = stringField(task, 'owner');
const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter(
@ -373,6 +455,64 @@ function formatTaskCommentPayload(
return `Comment: ${commentText}`;
}
function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null {
for (const key of keys) {
const value = payload[key];
if (Array.isArray(value)) {
return value.length;
}
}
return null;
}
function formatTaskCollectionPayload(payload: Record<string, unknown>): KnownPayloadPreview | null {
const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']);
const summary =
stringField(payload, 'summary') ??
stringField(payload, 'message') ??
stringField(payload, 'text');
if (taskCount != null) {
return {
title: 'Task list',
text: summary ? `${taskCount} tasks - ${summary}` : `${taskCount} tasks`,
};
}
return summary ? { title: 'Task list', text: summary } : null;
}
function formatRelationshipPayload(
payload: Record<string, unknown>,
fallbackInput?: Record<string, unknown> | null
): string | null {
const sourceRef = taskRefFromPayload(payload, fallbackInput);
const targetRef = formatTaskRef(
stringField(payload, 'targetId') ??
stringField(payload, 'targetTaskId') ??
stringField(fallbackInput ?? undefined, 'targetId') ??
stringField(fallbackInput ?? undefined, 'targetTaskId')
);
const relationship =
stringField(payload, 'relationship') ?? stringField(fallbackInput ?? undefined, 'relationship');
if (sourceRef && targetRef && relationship) return `${sourceRef} ${relationship} ${targetRef}`;
if (sourceRef && targetRef) return `${sourceRef} -> ${targetRef}`;
if (sourceRef) return sourceRef;
return targetRef;
}
function formatReviewChangesText(
payload: Record<string, unknown>,
fallbackInput?: Record<string, unknown> | null
): string | null {
return (
stringField(payload, 'comment') ??
stringField(payload, 'note') ??
stringField(payload, 'message') ??
stringField(fallbackInput ?? undefined, 'comment') ??
stringField(fallbackInput ?? undefined, 'note') ??
stringField(fallbackInput ?? undefined, 'message')
);
}
function formatTaskToolPayload(
payload: Record<string, unknown>,
canonicalToolNameValue: string | null,
@ -393,13 +533,42 @@ function formatTaskToolPayload(
const filename =
stringField(payload, 'filename') ??
stringField(payload, 'fileName') ??
stringField(payload, 'path') ??
stringField(payload, 'filePath') ??
stringField(fallbackInput ?? undefined, 'filename') ??
stringField(fallbackInput ?? undefined, 'fileName');
stringField(fallbackInput ?? undefined, 'fileName') ??
stringField(fallbackInput ?? undefined, 'path') ??
stringField(fallbackInput ?? undefined, 'filePath');
if (canonical === 'task_add_comment') {
const text = formatTaskCommentPayload(payload, fallbackInput);
return text ? { title: 'Comment added', text } : null;
}
if (canonical === 'task_get_comment') {
const text = formatTaskCommentPayload(payload, fallbackInput);
if (text) return { title: 'Comment loaded', text };
const commentId =
stringField(payload, 'commentId') ?? stringField(fallbackInput ?? undefined, 'commentId');
if (taskRef && commentId) {
return { title: 'Comment loaded', text: `${commentId} on ${taskRef}` };
}
return taskRef ? { title: 'Comment loaded', text: `Loaded comment on ${taskRef}` } : null;
}
if (canonical === 'task_create' || canonical === 'task_create_from_message') {
if (taskRef && taskSummary) {
return { title: 'Task created', text: `${taskRef}: ${taskSummary}` };
}
if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` };
}
if (canonical === 'task_list' || canonical === 'task_briefing') {
const collectionText = formatTaskCollectionPayload(payload);
if (collectionText) {
return {
title: canonical === 'task_briefing' ? 'Task briefing' : collectionText.title,
text: collectionText.text,
};
}
}
if (canonical === 'task_start') {
return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null;
}
@ -428,6 +597,19 @@ function formatTaskToolPayload(
if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` };
return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null;
}
if (canonical === 'task_attach_file') {
if (taskRef && filename) return { title: 'Task file', text: `${filename} on ${taskRef}` };
return taskRef ? { title: 'Task file', text: `Attached file to ${taskRef}` } : null;
}
if (canonical === 'task_link' || canonical === 'task_unlink') {
const relationshipText = formatRelationshipPayload(payload, fallbackInput);
if (relationshipText) {
return {
title: canonical === 'task_link' ? 'Tasks linked' : 'Tasks unlinked',
text: relationshipText,
};
}
}
if (canonical === 'review_request') {
const reviewer =
stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer');
@ -438,6 +620,21 @@ function formatTaskToolPayload(
if (canonical === 'review_start') {
return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null;
}
if (canonical === 'review_approve') {
const note = formatReviewChangesText(payload, fallbackInput);
if (taskRef && note) return { title: 'Review approved', text: `${taskRef}: ${note}` };
return taskRef ? { title: 'Review approved', text: `Approved ${taskRef}` } : null;
}
if (canonical === 'review_request_changes') {
const comment = formatReviewChangesText(payload, fallbackInput);
if (taskRef && comment) return { title: 'Changes requested', text: `${taskRef}: ${comment}` };
return taskRef
? { title: 'Changes requested', text: `Requested changes for ${taskRef}` }
: null;
}
if (canonical === 'task_restore') {
return taskRef ? { title: 'Task restored', text: `Restored ${taskRef}` } : null;
}
if (taskRef && status) {
return { title: 'Task update', text: `Task ${taskRef} ${status}` };
}
@ -510,6 +707,22 @@ function formatMessageSendPayload(payload: Record<string, unknown>): string | nu
return null;
}
function looksLikeMessageSendPayload(payload: Record<string, unknown>): boolean {
const routing = asRecord(payload.routing);
const messageRecord = asRecord(payload.message);
if (payload.deliveredToInbox === true || routing) {
return true;
}
return Boolean(
messageRecord &&
(stringField(messageRecord, 'to') ||
stringField(messageRecord, 'from') ||
stringField(messageRecord, 'summary') ||
stringField(messageRecord, 'text') ||
stringField(messageRecord, 'content'))
);
}
function formatMessageSendResultFromInput(payload: Record<string, unknown>): string | null {
const target = stringField(payload, 'to') ?? stringField(payload, 'target');
const summary =
@ -536,6 +749,27 @@ function formatMessageSendInputPayload(payload: Record<string, unknown>): string
return null;
}
function formatCrossTeamPayload(payload: Record<string, unknown>): string | null {
const routing = asRecord(payload.routing) ?? undefined;
const target =
stringField(payload, 'toTeam') ??
stringField(payload, 'targetTeam') ??
stringField(routing, 'toTeam') ??
stringField(routing, 'targetTeam') ??
stringField(routing, 'target');
const summary =
stringField(payload, 'summary') ??
stringField(payload, 'message') ??
stringField(payload, 'text') ??
stringField(payload, 'content') ??
stringField(routing, 'summary') ??
stringField(routing, 'content');
if (target && summary) return `to ${target}: ${summary}`;
if (target) return `to ${target}`;
if (summary) return summary;
return null;
}
function formatPlainToolResultStatus(
value: string,
toolContext: ToolUseContext | undefined
@ -552,6 +786,10 @@ function formatPlainToolResultStatus(
const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null;
return text ? { title: 'Message sent', text } : null;
}
if (toolContext.canonicalName === 'cross_team_send') {
const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null;
return text ? { title: 'Cross-team message', text } : null;
}
return (
formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ??
formatRuntimePayload({}, toolContext.canonicalName, fallbackInput)
@ -568,6 +806,13 @@ function formatTaskToolInputPayload(
const owner = stringField(payload, 'owner');
const clarification = stringField(payload, 'clarification');
const reviewer = stringField(payload, 'reviewer');
const commentId = stringField(payload, 'commentId');
const filename =
stringField(payload, 'filename') ??
stringField(payload, 'fileName') ??
stringField(payload, 'filePath');
const relationship = formatRelationshipPayload(payload, payload);
const reviewText = formatReviewChangesText(payload, payload);
if (canonicalToolNameValue === 'task_add_comment') {
if (taskRef && text) return `on ${taskRef}: ${text}`;
@ -575,6 +820,10 @@ function formatTaskToolInputPayload(
if (text) return text;
return null;
}
if (canonicalToolNameValue === 'task_get_comment') {
if (taskRef && commentId) return `${commentId} on ${taskRef}`;
if (taskRef) return `comment on ${taskRef}`;
}
if (canonicalToolNameValue === 'task_set_status') {
if (taskRef && status) return `${taskRef} -> ${status}`;
}
@ -587,6 +836,21 @@ function formatTaskToolInputPayload(
if (canonicalToolNameValue === 'review_request') {
if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`;
}
if (
canonicalToolNameValue === 'review_approve' ||
canonicalToolNameValue === 'review_request_changes'
) {
if (taskRef && reviewText) return `${taskRef}: ${reviewText}`;
}
if (
canonicalToolNameValue === 'task_attach_file' ||
canonicalToolNameValue === 'task_attach_comment_file'
) {
if (taskRef && filename) return `${filename} on ${taskRef}`;
}
if (canonicalToolNameValue === 'task_link' || canonicalToolNameValue === 'task_unlink') {
if (relationship) return relationship;
}
if (taskRef) return taskRef;
return null;
}
@ -616,13 +880,24 @@ function formatKnownPayloadPreview(
if (runtimeText) {
return runtimeText;
}
const messageText = formatMessageSendPayload(payload);
if (canonical === 'cross_team_send') {
const crossTeamText = formatCrossTeamPayload(payload);
if (crossTeamText) {
return { title: 'Cross-team message', text: crossTeamText };
}
}
const messageText =
canonical === 'sendmessage' ||
canonical === 'message_send' ||
looksLikeMessageSendPayload(payload)
? formatMessageSendPayload(payload)
: null;
if (messageText) {
return { title: 'Message sent', text: messageText };
}
const commentText = formatTaskCommentPayload(payload);
if (commentText) {
return { title: 'Comment added', text: commentText };
return { title: 'Comment', text: commentText };
}
const taskText = formatTaskStatusPayload(payload, fallbackInput);
if (taskText) {
@ -646,6 +921,10 @@ function previewUnknownValue(
if (plainStatus) {
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
}
const parsed = parseJsonLikeString(value);
if (parsed != null) {
return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
}
return truncatePreview(value, limit);
}
if (typeof value === 'number' || typeof value === 'boolean') {
@ -694,6 +973,13 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
return truncatePreview(formatted, limit);
}
}
if (canonical === 'cross_team_send') {
const payload = recordFromUnknown(value);
const formatted = payload ? formatCrossTeamPayload(payload) : null;
if (formatted) {
return truncatePreview(formatted, limit);
}
}
const payload = recordFromUnknown(value);
if (payload) {
const taskFormatted = formatTaskToolInputPayload(canonical, payload);
@ -722,6 +1008,118 @@ function extractTextPreview(
return preview.preview.length > 0 ? preview : null;
}
function firstQuotedLine(value: string): string | null {
const line = value
.split(/\r?\n/)
.map((item) => item.trim())
.find((item) => item.startsWith('>'));
return line ? line.replace(/^>\s*/, '').trim() || null : null;
}
function findLineByPrefix(value: string, prefix: string): string | null {
const normalizedPrefix = prefix.toLowerCase();
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.toLowerCase().startsWith(normalizedPrefix)) {
return trimmed;
}
}
return null;
}
function parseTaskAssignmentLine(line: string): { taskRef: string; subject?: string } | null {
const prefix = 'New task assigned to you:';
if (!line.toLowerCase().startsWith(prefix.toLowerCase())) {
return null;
}
const rest = line.slice(prefix.length).trim();
const [taskRefCandidate = '', ...restParts] = rest.split(/\s+/);
if (!taskRefCandidate.startsWith('#')) {
return null;
}
const restText = restParts.join(' ').trim();
const firstStar = restText.indexOf('*');
const secondStar = firstStar >= 0 ? restText.indexOf('*', firstStar + 1) : -1;
const subject =
firstStar >= 0 && secondStar > firstStar
? restText.slice(firstStar + 1, secondStar).trim()
: restText.replaceAll('*', '').trim();
return {
taskRef: taskRefCandidate,
...(subject ? { subject } : {}),
};
}
function parseCommentHeadingLine(line: string): { taskRef: string; subject?: string } | null {
const prefix = '**Comment on task ';
if (!line.toLowerCase().startsWith(prefix.toLowerCase())) {
return null;
}
const afterPrefix = line.slice(prefix.length);
const endRef = afterPrefix.indexOf('**');
if (endRef <= 0) {
return null;
}
const taskRef = afterPrefix.slice(0, endRef).trim();
if (!taskRef.startsWith('#')) {
return null;
}
const afterRef = afterPrefix.slice(endRef + 2).trim();
const firstUnderscore = afterRef.indexOf('_');
const secondUnderscore = firstUnderscore >= 0 ? afterRef.indexOf('_', firstUnderscore + 1) : -1;
const subject =
firstUnderscore >= 0 && secondUnderscore > firstUnderscore
? afterRef.slice(firstUnderscore + 1, secondUnderscore).trim()
: undefined;
return {
taskRef,
...(subject ? { subject } : {}),
};
}
function extractInboundTextPreview(
content: string | MemberLogPreviewContentBlock[],
textLimit: number
): { title: string; preview: string; truncated: boolean } | null {
const raw =
typeof content === 'string'
? content
: content
.filter((block): block is Extract<MemberLogPreviewContentBlock, { type: 'text' }> => {
return block.type === 'text' && typeof block.text === 'string';
})
.map((block) => block.text)
.join('\n');
const visibleRaw = removeHiddenInstructionBlocks(raw);
const compact = compactWhitespace(visibleRaw);
if (!compact) {
return null;
}
const assigned = parseTaskAssignmentLine(
findLineByPrefix(visibleRaw, 'New task assigned to you:') ?? ''
);
if (assigned) {
const taskRef = assigned.taskRef;
const subject = assigned.subject;
const preview = truncatePreview(subject ? `${taskRef} ${subject}` : taskRef, textLimit);
return { title: 'Task assigned', ...preview };
}
const comment = parseCommentHeadingLine(findLineByPrefix(visibleRaw, '**Comment on task ') ?? '');
if (comment) {
const taskRef = comment.taskRef;
const quoted = firstQuotedLine(visibleRaw);
const subject = comment.subject;
const text = quoted ?? subject ?? 'Comment received';
const preview = truncatePreview(`${taskRef}: ${text}`, textLimit);
return { title: 'Comment received', ...preview };
}
const preview = truncatePreview(compact, textLimit);
return preview.preview ? { title: 'Message', ...preview } : null;
}
function isToolUseBlock(
block: MemberLogPreviewContentBlock
): block is Extract<MemberLogPreviewContentBlock, { type: 'tool_use' }> {
@ -792,6 +1190,13 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string {
return message.role ?? message.type ?? '';
}
function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean {
if ((message.toolResults?.length ?? 0) > 0) {
return true;
}
return Array.isArray(message.content) && message.content.some(isToolResultBlock);
}
function buildItemId(input: {
provider: MemberLogStreamProvider;
sourceId: string;
@ -824,6 +1229,8 @@ function buildCandidate(input: {
laneId?: string;
token: string;
textTruncated: boolean;
toolUseKey?: string;
supersededByResult?: boolean;
}): Candidate {
const timestamp = timestampIso(input.message.timestamp);
const messageId = input.message.uuid ?? `message-${input.messageIndex}`;
@ -850,6 +1257,8 @@ function buildCandidate(input: {
timestampMs: timestampMs(input.message.timestamp),
order: input.messageIndex * 1_000 + input.blockIndex,
textTruncated: input.textTruncated,
...(input.toolUseKey ? { toolUseKey: input.toolUseKey } : {}),
...(input.supersededByResult ? { supersededByResult: true } : {}),
};
}
@ -873,6 +1282,11 @@ function collectToolUseCandidates(input: {
if (seen.has(id)) return;
seen.add(id);
const preview = previewToolInputValue(tool.name, tool.input, input.textLimit);
const toolUseKey = buildToolUseKey({
provider: input.provider,
sourceId: input.sourceId,
toolUseId: id,
});
candidates.push(
buildCandidate({
provider: input.provider,
@ -890,6 +1304,8 @@ function collectToolUseCandidates(input: {
laneId: input.laneId,
token: id,
textTruncated: preview.truncated,
toolUseKey,
supersededByResult: isToolUseSupersededBySuccessResult(tool.name),
})
);
};
@ -933,6 +1349,11 @@ function collectToolResultCandidates(input: {
if (seen.has(id)) return;
seen.add(id);
const toolContext = input.toolUseContexts.get(id);
const toolUseKey = buildToolUseKey({
provider: input.provider,
sourceId: input.sourceId,
toolUseId: id,
});
const preview = previewUnknownValue(
result.content,
input.textLimit,
@ -940,6 +1361,10 @@ function collectToolResultCandidates(input: {
toolContext
);
const isError = result.isError === true || preview.title === 'Tool error';
const title =
preview.title === 'Tool error'
? formatGenericToolResultTitle(toolContext, true)
: (preview.title ?? formatGenericToolResultTitle(toolContext, isError));
candidates.push(
buildCandidate({
provider: input.provider,
@ -948,7 +1373,7 @@ function collectToolResultCandidates(input: {
messageIndex: input.messageIndex,
blockIndex,
kind: 'tool_result',
title: isError ? 'Tool error' : (preview.title ?? 'Tool result'),
title,
preview: preview.preview,
tone: isError ? 'error' : 'success',
toolName: toolContext?.name,
@ -957,6 +1382,7 @@ function collectToolResultCandidates(input: {
laneId: input.laneId,
token: id,
textTruncated: preview.truncated,
toolUseKey,
})
);
};
@ -1078,9 +1504,50 @@ export function extractMemberLogPreviewItems(
);
}
}
if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) {
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
if (inboundPreview) {
candidates.push(
buildCandidate({
provider: input.provider,
sourceId,
message,
messageIndex,
blockIndex: 8,
kind: 'text',
title: inboundPreview.title,
preview: inboundPreview.preview,
tone: 'neutral',
sourceLabel: input.sourceLabel,
sessionId: input.sessionId ?? message.sessionId,
laneId: input.laneId,
token: 'inbound-text',
textTruncated: inboundPreview.truncated,
})
);
}
}
});
const sorted = [...candidates];
const successfulResultToolKeys = new Set(
candidates
.filter(
(candidate) =>
candidate.item.kind === 'tool_result' &&
candidate.item.tone !== 'error' &&
Boolean(candidate.item.preview?.trim())
)
.map((candidate) => candidate.toolUseKey)
.filter((toolUseKey): toolUseKey is string => Boolean(toolUseKey))
);
const compactCandidates = candidates.filter((candidate) => {
if (candidate.item.kind !== 'tool_use') return true;
if (!candidate.supersededByResult || !candidate.toolUseKey) return true;
return !successfulResultToolKeys.has(candidate.toolUseKey);
});
const sorted = [...compactCandidates];
sorted.sort((left, right) => {
const byTime = right.timestampMs - left.timestampMs;
if (byTime !== 0) return byTime;

View file

@ -29,6 +29,7 @@ import {
isConnectionManagedRuntimeProvider,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
@ -102,7 +103,7 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
}
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
return null;
return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null;
}
const usageHint = codex.localActiveChatgptAccountPresent
@ -731,6 +732,8 @@ const InstalledBanner = ({
provider.connection?.codex?.launchAllowed !== true &&
provider.connection?.codex?.login.status !== 'starting' &&
provider.connection?.codex?.login.status !== 'pending';
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
const disconnectAction = getProviderDisconnectAction(provider);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
@ -897,20 +900,33 @@ const InstalledBanner = ({
>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1">{codexDashboardHint}</span>
{codexNeedsReconnect ? (
<button
type="button"
onClick={onCodexReconnect}
disabled={codexReconnectBusy || actionDisabled}
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
color: '#fbbf24',
}}
>
Reconnect ChatGPT
</button>
{showCodexLoginActions ? (
<>
<CodexLoginLinkCopyButton
authUrl={codexLoginAuthUrl}
disabled={codexReconnectBusy || actionDisabled}
size="xs"
/>
<button
type="button"
onClick={() => {
if (codexLoginAuthUrl) {
void api.openExternal(codexLoginAuthUrl);
return;
}
onCodexReconnect();
}}
disabled={codexReconnectBusy || actionDisabled}
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
color: '#fbbf24',
}}
>
{codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'}
</button>
</>
) : null}
</div>
</div>

View file

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
interface CodexLoginLinkCopyButtonProps {
authUrl?: string | null;
disabled?: boolean;
size?: 'xs' | 'sm';
}
export function CodexLoginLinkCopyButton({
authUrl,
disabled = false,
size = 'sm',
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
useEffect(() => {
setCopyState('idle');
}, [authUrl]);
if (!authUrl) {
return null;
}
const handleCopyAuthUrl = (): void => {
if (!navigator.clipboard) {
setCopyState('failed');
return;
}
void navigator.clipboard.writeText(authUrl).then(
() => setCopyState('copied'),
() => setCopyState('failed')
);
};
const sizeClassName = size === 'xs' ? 'px-2 py-1 text-[10px]' : 'px-2.5 py-1.5 text-xs';
return (
<button
type="button"
onClick={handleCopyAuthUrl}
disabled={disabled}
className={`inline-flex shrink-0 items-center gap-1 rounded-md border font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50 ${sizeClassName}`}
style={{
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
}}
title="Copy ChatGPT login link"
>
{copyState === 'copied' ? <Check className="size-3" /> : <Copy className="size-3" />}
{copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy link'}
</button>
);
}

View file

@ -39,6 +39,7 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
import { useStore } from '@renderer/store';
import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react';
@ -715,6 +716,7 @@ export const ProviderRuntimeSettingsDialog = ({
Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession;
const codexLoginPending =
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
@ -1389,14 +1391,31 @@ export const ProviderRuntimeSettingsDialog = ({
Refresh
</Button>
{codexLoginPending ? (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexCancelLogin()}
>
Cancel login
</Button>
<>
<CodexLoginLinkCopyButton
authUrl={codexLoginAuthUrl}
disabled={codexActionBusy}
/>
{codexLoginAuthUrl ? (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void api.openExternal(codexLoginAuthUrl)}
>
<Link2 className="mr-1 size-3.5" />
Open login
</Button>
) : null}
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexCancelLogin()}
>
Cancel login
</Button>
</>
) : codexHasActiveChatgptSession ? (
<Button
size="sm"
@ -1407,15 +1426,21 @@ export const ProviderRuntimeSettingsDialog = ({
Disconnect account
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexStartLogin()}
>
<Link2 className="mr-1 size-3.5" />
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
</Button>
<>
<CodexLoginLinkCopyButton
authUrl={codexLoginAuthUrl}
disabled={codexActionBusy}
/>
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexStartLogin()}
>
<Link2 className="mr-1 size-3.5" />
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
</Button>
</>
)}
</div>
</div>

View file

@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils';
import { markTaskUnread } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
@ -283,6 +284,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
setRenamingTaskKey(null);
}, []);
const handleMarkTaskUnread = useCallback((teamName: string, taskId: string): void => {
markTaskUnread(teamName, taskId);
}, []);
const handleDeleteTask = useCallback(
async (teamName: string, taskId: string): Promise<void> => {
const confirmed = await confirm({
@ -548,6 +553,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
isArchived={false}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
@ -641,6 +647,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
@ -726,6 +733,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
onToggleArchive={() =>
taskLocalState.toggleArchive(task.teamName, task.id)
}
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
@ -832,6 +840,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
onToggleArchive={() =>
taskLocalState.toggleArchive(task.teamName, task.id)
}
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
>

View file

@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { clearTaskManualUnread } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
@ -157,6 +158,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {
clearTaskManualUnread(task.teamName, task.id);
openGlobalTaskDetail(task.teamName, task.id);
}
}}

View file

@ -5,7 +5,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/components/ui/context-menu';
import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
import type { GlobalTask } from '@shared/types';
@ -15,6 +15,7 @@ export interface TaskContextMenuProps {
isArchived: boolean;
onTogglePin: () => void;
onToggleArchive: () => void;
onMarkUnread: () => void;
onRename: () => void;
onDelete?: () => void;
children: React.ReactNode;
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
isArchived,
onTogglePin,
onToggleArchive,
onMarkUnread,
onRename,
onDelete,
children,
@ -55,6 +57,11 @@ export const TaskContextMenu = ({
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onMarkUnread}>
<Mail className="size-3.5 shrink-0" />
<span>Mark as unread</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onToggleArchive}>
@ -74,10 +81,7 @@ export const TaskContextMenu = ({
{onDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={onDelete}
className="text-red-400 focus:text-red-400"
>
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
<Trash2 className="size-3.5 shrink-0" />
<span>Delete task</span>
</ContextMenuItem>

View file

@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
return (
<MemberList
{...props}
teamName={teamName}
leadActivity={leadActivity}
memberSpawnStatuses={memberSpawnStatusMap}
memberRuntimeEntries={memberRuntimeMap}

View file

@ -0,0 +1,101 @@
import React from 'react';
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
import { LogIn } from 'lucide-react';
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
import type { CliInstallationStatus, TeamProviderId } from '@shared/types';
function containsReconnectCue(text: string | null | undefined): boolean {
if (!text) {
return false;
}
const lower = text.toLowerCase();
return lower.includes('session needs reconnect') || lower.includes('reconnect chatgpt');
}
export function shouldShowCodexReconnectPrompt({
effectiveCliStatus,
selectedProviderIds,
prepareMessage,
prepareChecks,
}: {
effectiveCliStatus: CliInstallationStatus | null;
selectedProviderIds: readonly TeamProviderId[];
prepareMessage: string | null;
prepareChecks: readonly ProvisioningProviderCheck[];
}): boolean {
if (!selectedProviderIds.includes('codex')) {
return false;
}
const codexProvider = effectiveCliStatus?.providers.find(
(provider) => provider.providerId === 'codex'
);
const codexConnection = codexProvider?.connection?.codex;
const loginStatus = codexConnection?.login.status;
const loginPending = loginStatus === 'starting' || loginStatus === 'pending';
if (loginPending && codexConnection?.login.authUrl) {
return true;
}
const codexNeedsReconnect =
Boolean(codexConnection?.localActiveChatgptAccountPresent) &&
codexConnection?.launchAllowed !== true &&
!loginPending;
if (!codexNeedsReconnect) {
return false;
}
if (containsReconnectCue(prepareMessage)) {
return true;
}
return prepareChecks.some(
(check) =>
check.providerId === 'codex' && check.details.some((detail) => containsReconnectCue(detail))
);
}
export function CodexReconnectPrompt({
authUrl,
reconnectBusy,
onReconnect,
}: {
authUrl: string | null;
reconnectBusy: boolean;
onReconnect: () => void;
}): React.JSX.Element {
return (
<div
className="mt-2 rounded-md border px-2.5 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
}}
>
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-[11px] text-amber-100/90">
Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then
finish login in the browser and retry this dialog.
</p>
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
<button
type="button"
onClick={onReconnect}
disabled={reconnectBusy}
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'rgba(245, 158, 11, 0.34)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
}}
>
<LogIn className="size-3" />
{reconnectBusy ? 'Opening...' : 'Reconnect ChatGPT'}
</button>
</div>
</div>
);
}

View file

@ -88,6 +88,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import {
clearInheritedMemberModelsUnavailableForProvider,
@ -95,6 +96,7 @@ import {
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
@ -155,7 +157,6 @@ const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskU
import type {
EffortLevel,
Project,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
@ -402,7 +403,7 @@ export const CreateTeamDialog = ({
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
// ── Transient UI state (NOT persisted) ───────────────────────────────
const [projects, setProjects] = useState<Project[]>([]);
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
@ -709,6 +710,19 @@ export const CreateTeamDialog = ({
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = useCallback(() => {
void (async () => {
const success = await codexAccount.startChatgptLogin();
if (success) {
await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}
})();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
prepareRequestSeqRef.current += 1;
@ -948,36 +962,11 @@ export const CreateTeamDialog = ({
let cancelled = false;
void (async () => {
try {
const nextProjects = (await api.getProjects()).filter(
(project) => !isEphemeralProjectPath(project.path)
);
const nextProjects = await loadProjectPathProjects({ defaultProjectPath });
if (cancelled) {
return;
}
// If defaultProjectPath is set but not in the fetched list (e.g. new project
// without Claude sessions), add it as a synthetic entry so the Combobox can
// display and select it.
const normalizedDefaultProjectPath = defaultProjectPath
? normalizePath(defaultProjectPath)
: null;
if (
defaultProjectPath &&
normalizedDefaultProjectPath &&
!isEphemeralProjectPath(defaultProjectPath) &&
!nextProjects.some((p) => normalizePath(p.path) === normalizedDefaultProjectPath)
) {
const folderName =
defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath;
nextProjects.unshift({
id: defaultProjectPath.replace(/[/\\]/g, '-'),
path: defaultProjectPath,
name: folderName,
sessions: [],
createdAt: Date.now(),
});
}
setProjects(nextProjects);
} catch (error) {
if (cancelled) {
@ -1552,6 +1541,12 @@ export const CreateTeamDialog = ({
}),
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
selectedProviderIds: selectedMemberProviders,
prepareMessage: effectivePrepare.message,
prepareChecks,
});
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
@ -2117,8 +2112,8 @@ export const CreateTeamDialog = ({
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{prepareWarnings.map((warning, index) => (
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
{warning}
</p>
))}
@ -2152,9 +2147,9 @@ export const CreateTeamDialog = ({
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning) => (
{prepareWarnings.map((warning, index) => (
<p
key={warning}
key={`${index}:${warning}`}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
@ -2166,6 +2161,15 @@ export const CreateTeamDialog = ({
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
</p>
{showCodexReconnectPrompt ? (
<div className="pl-6">
<CodexReconnectPrompt
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={handleCodexReconnect}
/>
</div>
) : null}
</div>
) : null}
</div>

View file

@ -91,6 +91,7 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import { EffortLevelSelector } from './EffortLevelSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
@ -100,6 +101,7 @@ import {
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
@ -153,7 +155,6 @@ import type { MentionSuggestion } from '@renderer/types/mention';
import type {
CreateScheduleInput,
EffortLevel,
Project,
ResolvedTeamMember,
Schedule,
ScheduleLaunchConfig,
@ -404,7 +405,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const chipDraft = useChipDraftPersistence(
`launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips`
);
const [projects, setProjects] = useState<Project[]>([]);
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
@ -586,6 +587,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = React.useCallback(() => {
void (async () => {
const success = await codexAccount.startChatgptLogin();
if (success) {
await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}
})();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
// Schedule store actions
const createSchedule = useStore((s) => s.createSchedule);
const updateSchedule = useStore((s) => s.updateSchedule);
@ -1579,6 +1593,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// ---------------------------------------------------------------------------
const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
useEffect(() => {
if (!open) return;
@ -1589,30 +1604,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
let cancelled = false;
void (async () => {
try {
const apiProjects = (await api.getProjects()).filter(
(project) => !isEphemeralProjectPath(project.path)
);
const nextProjects = await loadProjectPathProjects({
defaultProjectPath,
repositoryGroups,
});
if (cancelled) return;
const pathSet = new Set(apiProjects.map((p) => p.path));
const extras: Project[] = [];
for (const repo of repositoryGroups) {
for (const wt of repo.worktrees) {
if (!isEphemeralProjectPath(wt.path) && !pathSet.has(wt.path)) {
pathSet.add(wt.path);
extras.push({
id: wt.id,
path: wt.path,
name: wt.name,
sessions: [],
totalSessions: 0,
createdAt: wt.createdAt ?? Date.now(),
});
}
}
}
setProjects([...apiProjects, ...extras]);
setProjects(nextProjects);
} catch (error) {
if (cancelled) return;
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
@ -1625,10 +1623,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
}, [open, repositoryGroups]);
}, [open, repositoryGroups, defaultProjectPath]);
// Pre-select defaultProjectPath (launch mode) or first project
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
useEffect(() => {
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
@ -1920,6 +1917,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}),
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
selectedProviderIds: selectedMemberProviders,
prepareMessage: effectivePrepare.message,
prepareChecks,
});
const launchInFlight = useStore((s) =>
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
);
@ -2819,8 +2822,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{prepareWarnings.map((warning, index) => (
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
{warning}
</p>
))}
@ -2858,9 +2861,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning) => (
{prepareWarnings.map((warning, index) => (
<p
key={warning}
key={`${index}:${warning}`}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
@ -2889,6 +2892,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</button>
) : null}
</div>
{showCodexReconnectPrompt ? (
<div className="pl-6">
<CodexReconnectPrompt
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={handleCodexReconnect}
/>
</div>
) : null}
</div>
) : null}
</div>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { api } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
@ -8,9 +9,14 @@ import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { Check, FolderOpen } from 'lucide-react';
import { buildProjectPathOptions } from './projectPathOptions';
import {
buildProjectPathOptions,
type ProjectPathOptionMeta,
type ProjectPathProject,
} from './projectPathOptions';
import type { Project } from '@shared/types';
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -45,6 +51,49 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element {
);
}
function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | undefined {
return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource;
}
function getSourceLabel(source: DashboardRecentProjectSource): string {
switch (source) {
case 'claude':
return 'Found by Claude';
case 'codex':
return 'Found by Codex';
case 'mixed':
return 'Found by Claude and Codex';
}
}
function ProjectSourceBadge({
source,
}: {
source?: DashboardRecentProjectSource;
}): React.JSX.Element | null {
if (!source) {
return null;
}
const logos =
source === 'mixed'
? (['anthropic', 'codex'] as const)
: source === 'codex'
? (['codex'] as const)
: (['anthropic'] as const);
return (
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-1 py-0.5"
title={getSourceLabel(source)}
>
{logos.map((providerId) => (
<ProviderBrandLogo key={providerId} providerId={providerId} className="size-3" />
))}
</span>
);
}
export type CwdMode = 'project' | 'custom';
interface ProjectPathSelectorProps {
@ -54,7 +103,7 @@ interface ProjectPathSelectorProps {
onSelectedProjectPathChange: (path: string) => void;
customCwd: string;
onCustomCwdChange: (cwd: string) => void;
projects: Project[];
projects: ProjectPathProject[];
projectsLoading: boolean;
projectsError: string | null;
fieldError?: string | null;
@ -123,6 +172,12 @@ export const ProjectPathSelector = ({
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projectOptions.length === 0}
renderTriggerLabel={(option) => (
<span className="flex min-w-0 items-center gap-1.5">
<ProjectSourceBadge source={getOptionSource(option)} />
<span className="min-w-0 truncate">{option.label}</span>
</span>
)}
renderOption={(option, isSelected, query) => (
<>
<Check
@ -131,6 +186,7 @@ export const ProjectPathSelector = ({
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<ProjectSourceBadge source={getOptionSource(option)} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}

View file

@ -619,9 +619,9 @@ export const ProvisioningProviderStatusList = ({
</div>
{visibleDetails.length > 0 ? (
<div className="mt-0.5 space-y-0.5 pl-4">
{visibleDetails.map((detail) => (
{visibleDetails.map((detail, index) => (
<p
key={detail}
key={`${check.providerId}:${index}:${detail}`}
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
>
{detail}

View file

@ -563,7 +563,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span className="leading-tight">{opt.label}</span>
<span
className={cn('leading-tight', opt.value === 'gpt-5.5' && 'font-bold')}
>
{opt.label}
</span>
{sourceBadgeLabel ? (
<span
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"

View file

@ -2,13 +2,25 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
import type { Project } from '@shared/types';
function toProjectOption(project: Project): ComboboxOption {
export interface ProjectPathProject extends Project {
discoverySource?: DashboardRecentProjectSource;
}
export interface ProjectPathOptionMeta {
discoverySource?: DashboardRecentProjectSource;
}
function toProjectOption(project: ProjectPathProject): ComboboxOption {
return {
value: project.path,
label: project.name,
description: project.path,
meta: {
discoverySource: project.discoverySource,
} satisfies ProjectPathOptionMeta,
};
}
@ -17,7 +29,7 @@ function toProjectOption(project: Project): ComboboxOption {
* This keeps combobox item values unique even when scanner sources overlap.
*/
export function buildProjectPathOptions(
projects: Project[],
projects: ProjectPathProject[],
preferredPath?: string
): ComboboxOption[] {
const options: ComboboxOption[] = [];

View file

@ -0,0 +1,140 @@
import { api } from '@renderer/api';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import type { ProjectPathProject } from './projectPathOptions';
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
import type { Project, RepositoryGroup } from '@shared/types';
export type { ProjectPathProject } from './projectPathOptions';
interface LoadProjectPathProjectsOptions {
defaultProjectPath?: string | null;
repositoryGroups?: RepositoryGroup[];
}
function mergeDiscoverySource(
current: DashboardRecentProjectSource | undefined,
next: DashboardRecentProjectSource | undefined
): DashboardRecentProjectSource | undefined {
if (!current) return next;
if (!next || current === next) return current;
return 'mixed';
}
function getPathName(projectPath: string): string {
return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath;
}
function upsertProject(
byNormalizedPath: Map<string, ProjectPathProject>,
order: string[],
project: ProjectPathProject
): void {
if (isEphemeralProjectPath(project.path)) {
return;
}
const normalizedPath = normalizePath(project.path);
const existing = byNormalizedPath.get(normalizedPath);
if (!existing) {
byNormalizedPath.set(normalizedPath, project);
order.push(normalizedPath);
return;
}
existing.discoverySource = mergeDiscoverySource(
existing.discoverySource,
project.discoverySource
);
if (!existing.mostRecentSession && project.mostRecentSession) {
existing.mostRecentSession = project.mostRecentSession;
}
}
function recentProjectToProject(project: {
id: string;
name: string;
primaryPath: string;
mostRecentActivity: number;
source: DashboardRecentProjectSource;
}): ProjectPathProject {
return {
id: `recent:${project.id}`,
path: project.primaryPath,
name: project.name,
sessions: [],
totalSessions: 0,
createdAt: project.mostRecentActivity,
mostRecentSession: project.mostRecentActivity,
discoverySource: project.source,
};
}
function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project {
return {
id: worktree.id,
path: worktree.path,
name: worktree.name,
sessions: [],
totalSessions: 0,
createdAt: worktree.createdAt ?? Date.now(),
};
}
function syntheticProjectFromPath(projectPath: string): Project {
return {
id: projectPath.replace(/[/\\]/g, '-'),
path: projectPath,
name: getPathName(projectPath),
sessions: [],
totalSessions: 0,
createdAt: Date.now(),
};
}
export async function loadProjectPathProjects({
defaultProjectPath,
repositoryGroups = [],
}: LoadProjectPathProjectsOptions = {}): Promise<ProjectPathProject[]> {
const [projectsResult, recentProjectsResult] = await Promise.allSettled([
api.getProjects(),
api.getDashboardRecentProjects(),
]);
if (projectsResult.status === 'rejected' && recentProjectsResult.status === 'rejected') {
throw projectsResult.reason;
}
const byNormalizedPath = new Map<string, ProjectPathProject>();
const order: string[] = [];
const apiProjects = projectsResult.status === 'fulfilled' ? projectsResult.value : [];
const recentProjects =
recentProjectsResult.status === 'fulfilled' ? recentProjectsResult.value.projects : [];
for (const project of apiProjects) {
upsertProject(byNormalizedPath, order, {
...project,
discoverySource: 'claude',
});
}
for (const project of recentProjects) {
upsertProject(byNormalizedPath, order, recentProjectToProject(project));
}
for (const repo of repositoryGroups) {
for (const worktree of repo.worktrees) {
upsertProject(byNormalizedPath, order, repositoryWorktreeToProject(worktree));
}
}
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
upsertProject(byNormalizedPath, order, syntheticProjectFromPath(defaultProjectPath));
}
return order.flatMap((path) => {
const project = byNormalizedPath.get(path);
return project ? [project] : [];
});
}

View file

@ -1,8 +1,14 @@
import { memo } from 'react';
import { memo, useEffect, useState } from 'react';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import {
formatMemberActivityElapsed,
readMemberActivityTimerElapsed,
syncMemberActivityTimer,
} from '@renderer/utils/memberActivityTimer';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
import type { TeamTaskWithKanban } from '@shared/types';
interface CurrentTaskIndicatorProps {
@ -10,9 +16,71 @@ interface CurrentTaskIndicatorProps {
borderColor: string;
maxSubjectLength?: number;
activityLabel?: string;
activityTimer?: MemberActivityTimerAnchor | null;
isTimerRunning?: boolean;
onOpenTask?: () => void;
}
function useActivityTimerLabel(
activityTimer: MemberActivityTimerAnchor | null | undefined,
isTimerRunning: boolean
): string | null {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!activityTimer) return;
const now = Date.now();
syncMemberActivityTimer({
timerId: activityTimer.timerId,
startedAtMs: activityTimer.startedAtMs,
baseElapsedMs: activityTimer.baseElapsedMs,
running: isTimerRunning,
runId: activityTimer.runId,
nowMs: now,
});
return () => {
syncMemberActivityTimer({
timerId: activityTimer.timerId,
startedAtMs: activityTimer.startedAtMs,
baseElapsedMs: activityTimer.baseElapsedMs,
running: isTimerRunning,
runId: activityTimer.runId,
nowMs: Date.now(),
});
};
}, [activityTimer, isTimerRunning]);
useEffect(() => {
if (!activityTimer || !isTimerRunning) return;
const handle = window.setInterval(() => {
const now = Date.now();
syncMemberActivityTimer({
timerId: activityTimer.timerId,
startedAtMs: activityTimer.startedAtMs,
baseElapsedMs: activityTimer.baseElapsedMs,
running: true,
runId: activityTimer.runId,
nowMs: now,
});
setNowMs(now);
}, 1000);
return () => window.clearInterval(handle);
}, [activityTimer, isTimerRunning]);
if (!activityTimer) return null;
return formatMemberActivityElapsed(
readMemberActivityTimerElapsed({
timerId: activityTimer.timerId,
startedAtMs: activityTimer.startedAtMs,
baseElapsedMs: activityTimer.baseElapsedMs,
running: isTimerRunning,
runId: activityTimer.runId,
nowMs,
})
);
}
/**
* Inline indicator showing a spinning loader + "working on" + task label button.
* Shared between MemberCard and MemberHoverCard.
@ -23,8 +91,11 @@ export const CurrentTaskIndicator = memo(
borderColor,
maxSubjectLength,
activityLabel = 'working on',
activityTimer,
isTimerRunning = true,
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
const subjectText =
typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
@ -54,6 +125,14 @@ export const CurrentTaskIndicator = memo(
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
{timerLabel ? (
<span
className="shrink-0 text-[9px] font-medium tabular-nums text-[var(--color-text-muted)]"
title={`Active for ${timerLabel}`}
>
{timerLabel}
</span>
) : null}
</div>
);
}

View file

@ -30,6 +30,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberPresenceDot } from './MemberPresenceDot';
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
@ -54,6 +55,10 @@ interface MemberCardProps {
leadActivity?: LeadActivityState;
currentTask?: TeamTaskWithKanban | null;
reviewTask?: TeamTaskWithKanban | null;
currentTaskTimer?: MemberActivityTimerAnchor | null;
reviewTaskTimer?: MemberActivityTimerAnchor | null;
currentTaskTimerRunning?: boolean;
reviewTaskTimerRunning?: boolean;
isAwaitingReply?: boolean;
isRemoved?: boolean;
spawnStatus?: MemberSpawnStatus;
@ -132,6 +137,10 @@ export const MemberCard = memo(function MemberCard({
leadActivity,
currentTask,
reviewTask,
currentTaskTimer,
reviewTaskTimer,
currentTaskTimerRunning = isTeamAlive !== false,
reviewTaskTimerRunning = isTeamAlive !== false,
isAwaitingReply,
isRemoved,
spawnStatus,
@ -433,6 +442,8 @@ export const MemberCard = memo(function MemberCard({
task={currentTask}
borderColor={colors.border}
activityLabel="working on"
activityTimer={currentTaskTimer}
isTimerRunning={currentTaskTimerRunning}
onOpenTask={onOpenTask}
/>
) : null}
@ -441,6 +452,8 @@ export const MemberCard = memo(function MemberCard({
task={reviewTask}
borderColor={colors.border}
activityLabel="reviewing"
activityTimer={reviewTaskTimer}
isTimerRunning={reviewTaskTimerRunning}
onOpenTask={onOpenReviewTask}
/>
) : null}

View file

@ -23,14 +23,15 @@ import {
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
shouldDisplayMemberCurrentTask,
} from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ExternalLink } from 'lucide-react';
@ -42,7 +43,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberPresenceDot } from './MemberPresenceDot';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
import type { TeamTaskWithKanban } from '@shared/types';
interface MemberHoverCardProps {
/** The member name to look up */
@ -131,7 +132,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
const currentTask =
isDisplayableCurrentTask(currentTaskCandidate) &&
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeEntry,
})
? currentTaskCandidate
: null;
const presentationMember =
member.currentTaskId && !currentTask
? {

View file

@ -1,6 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
deriveReviewActivityTimerAnchor,
deriveWorkActivityTimerAnchor,
syncMemberActivityTimer,
} from '@renderer/utils/memberActivityTimer';
import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/utils/memberHelpers';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -9,6 +14,7 @@ import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { MemberCard } from './MemberCard';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
@ -22,6 +28,7 @@ import type {
} from '@shared/types';
interface MemberListProps {
teamName?: string;
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTaskWithKanban>;
@ -101,6 +108,45 @@ function areTaskStatusCountsMapsEquivalent(
return true;
}
function areTaskWorkIntervalsEquivalent(
left: TeamTaskWithKanban['workIntervals'],
right: TeamTaskWithKanban['workIntervals']
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
return left.every((interval, index) => {
const other = right[index];
if (!other) return false;
return interval.startedAt === other.startedAt && interval.completedAt === other.completedAt;
});
}
function areTaskHistoryEventsEquivalent(
left: TeamTaskWithKanban['historyEvents'],
right: TeamTaskWithKanban['historyEvents']
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
return left.every((event, index) => {
const other = right[index];
if (!other) return false;
const leftRow = event as unknown as Record<string, unknown>;
const rightRow = other as unknown as Record<string, unknown>;
return (
event.id === other.id &&
event.type === other.type &&
event.timestamp === other.timestamp &&
leftRow.actor === rightRow.actor &&
leftRow.reviewer === rightRow.reviewer &&
leftRow.from === rightRow.from &&
leftRow.to === rightRow.to &&
leftRow.status === rightRow.status
);
});
}
function areMemberTaskMapsEquivalent(
left: Map<string, TeamTaskWithKanban> | undefined,
right: Map<string, TeamTaskWithKanban> | undefined
@ -118,7 +164,9 @@ function areMemberTaskMapsEquivalent(
leftTask.status !== rightTask.status ||
leftTask.reviewer !== rightTask.reviewer ||
leftTask.reviewState !== rightTask.reviewState ||
leftTask.kanbanColumn !== rightTask.kanbanColumn
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
) {
return false;
}
@ -243,6 +291,7 @@ function areMemberListPropsEqual(
next: Readonly<MemberListProps>
): boolean {
return (
prev.teamName === next.teamName &&
areResolvedMembersEquivalent(prev.members, next.members) &&
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
@ -270,6 +319,10 @@ interface MemberCardRowProps {
memberColor: string;
currentTask: TeamTaskWithKanban | null;
reviewTask: TeamTaskWithKanban | null;
currentTaskTimer: MemberActivityTimerAnchor | null;
reviewTaskTimer: MemberActivityTimerAnchor | null;
currentTaskTimerRunning: boolean;
reviewTaskTimerRunning: boolean;
awaitingReply: boolean;
taskCounts?: TaskStatusCounts | null;
runtimeSummary?: string;
@ -299,6 +352,10 @@ const MemberCardRow = memo(function MemberCardRow({
memberColor,
currentTask,
reviewTask,
currentTaskTimer,
reviewTaskTimer,
currentTaskTimerRunning,
reviewTaskTimerRunning,
awaitingReply,
taskCounts,
runtimeSummary,
@ -346,6 +403,10 @@ const MemberCardRow = memo(function MemberCardRow({
leadActivity={isLeadMember(member) ? leadActivity : undefined}
currentTask={currentTask}
reviewTask={reviewTask}
currentTaskTimer={currentTaskTimer}
reviewTaskTimer={reviewTaskTimer}
currentTaskTimerRunning={currentTaskTimerRunning}
reviewTaskTimerRunning={reviewTaskTimerRunning}
isAwaitingReply={awaitingReply}
isRemoved={isRemoved}
runtimeSummary={runtimeSummary}
@ -370,6 +431,7 @@ const MemberCardRow = memo(function MemberCardRow({
});
export const MemberList = memo(function MemberList({
teamName = '__unknown_team__',
members,
memberTaskCounts,
taskMap,
@ -434,6 +496,124 @@ export const MemberList = memo(function MemberList({
return result;
}, [taskMap]);
const isMemberActivityTimerRunning = useCallback(
(
spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry: TeamAgentRuntimeEntry | undefined
): boolean => {
if (isTeamAlive === false) return false;
if (
spawnEntry?.status === 'offline' ||
spawnEntry?.status === 'error' ||
spawnEntry?.status === 'skipped'
) {
return false;
}
if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') {
return false;
}
if (
runtimeEntry?.livenessKind === 'shell_only' ||
runtimeEntry?.livenessKind === 'registered_only' ||
runtimeEntry?.livenessKind === 'stale_metadata' ||
runtimeEntry?.livenessKind === 'not_found'
) {
return false;
}
return true;
},
[isTeamAlive]
);
const getActivityTimerRunId = useCallback(
(running: boolean): string | null => {
if (!running) return null;
return runtimeRunId ?? 'runtime:unknown';
},
[runtimeRunId]
);
const withActivityTimerRunId = useCallback(
(
anchor: MemberActivityTimerAnchor | null,
running: boolean
): MemberActivityTimerAnchor | null => {
if (!anchor) return null;
return {
...anchor,
runId: getActivityTimerRunId(running),
};
},
[getActivityTimerRunId]
);
useEffect(() => {
if (!taskMap) return;
const nowMs = Date.now();
for (const member of activeMembers) {
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
const running = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
const currentTaskCandidate = member.currentTaskId
? (taskMap.get(member.currentTaskId) ?? null)
: null;
if (isDisplayableCurrentTask(currentTaskCandidate)) {
const anchor = deriveWorkActivityTimerAnchor(currentTaskCandidate, {
teamName,
memberName: member.name,
});
if (anchor) {
const visible =
running &&
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeEntry,
});
syncMemberActivityTimer({
timerId: anchor.timerId,
startedAtMs: anchor.startedAtMs,
baseElapsedMs: anchor.baseElapsedMs,
running: visible,
runId: getActivityTimerRunId(visible),
nowMs,
});
}
}
const reviewTask = reviewTaskByMember.get(member.name) ?? null;
if (reviewTask) {
const anchor = deriveReviewActivityTimerAnchor(reviewTask, {
teamName,
memberName: member.name,
});
if (anchor) {
syncMemberActivityTimer({
timerId: anchor.timerId,
startedAtMs: anchor.startedAtMs,
baseElapsedMs: anchor.baseElapsedMs,
running,
runId: getActivityTimerRunId(running),
nowMs,
});
}
}
}
}, [
activeMembers,
getActivityTimerRunId,
isMemberActivityTimerRunning,
isTeamAlive,
memberRuntimeEntries,
memberSpawnStatuses,
reviewTaskByMember,
taskMap,
teamName,
]);
const buildRuntimeSummary = useCallback(
(
member: ResolvedTeamMember,
@ -457,16 +637,44 @@ export const MemberList = memo(function MemberList({
<div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}>
{activeMembers.map((member) => {
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
const currentTaskCandidate =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const currentTask =
isDisplayableCurrentTask(currentTaskCandidate) &&
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeEntry,
})
? currentTaskCandidate
: null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask =
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
const currentTaskTimer = withActivityTimerRunId(
currentTask
? deriveWorkActivityTimerAnchor(currentTask, {
teamName,
memberName: member.name,
})
: null,
activityTimerRunning
);
const reviewTaskTimer = withActivityTimerRunId(
reviewTask
? deriveReviewActivityTimerAnchor(reviewTask, {
teamName,
memberName: member.name,
})
: null,
activityTimerRunning
);
return (
<MemberCardRow
key={member.name}
@ -475,6 +683,10 @@ export const MemberList = memo(function MemberList({
memberColor={colorMap.get(member.name) ?? 'blue'}
currentTask={currentTask}
reviewTask={reviewTask}
currentTaskTimer={currentTaskTimer}
reviewTaskTimer={reviewTaskTimer}
currentTaskTimerRunning={activityTimerRunning}
reviewTaskTimerRunning={activityTimerRunning}
awaitingReply={
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
}
@ -516,6 +728,10 @@ export const MemberList = memo(function MemberList({
memberColor={colorMap.get(member.name) ?? 'blue'}
currentTask={null}
reviewTask={null}
currentTaskTimer={null}
reviewTaskTimer={null}
currentTaskTimerRunning={false}
reviewTaskTimerRunning={false}
awaitingReply={false}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}

View file

@ -14,6 +14,7 @@ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface TaskReadEntry {
readIds: string[];
lastUpdated: number;
manualUnread?: boolean;
}
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
@ -116,9 +117,12 @@ export function getSnapshot(): ReadState {
* Mark specific comment IDs as read for a given team/task.
*/
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
if (commentIds.length === 0) return;
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (commentIds.length === 0) {
if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId);
return;
}
const prevSet = new Set(prev?.readIds ?? []);
let changed = false;
for (const id of commentIds) {
@ -127,7 +131,7 @@ export function markCommentsRead(teamName: string, taskId: string, commentIds: s
changed = true;
}
}
if (!changed) return;
if (!changed && !prev?.manualUnread) return;
cache = {
...cache,
[key]: {
@ -148,7 +152,7 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
const prev = cache[key];
// Update lastUpdated to at least this timestamp (for legacy migration support)
const prevLastUpdated = prev?.lastUpdated ?? 0;
if (latestTimestamp <= prevLastUpdated && prev) return;
if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return;
cache = {
...cache,
[key]: {
@ -160,6 +164,43 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
scheduleSave();
}
/**
* Manually mark a task as unread even when it has no unread comments.
*/
export function markTaskUnread(teamName: string, taskId: string): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (prev?.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: prev?.readIds ?? [],
lastUpdated: Date.now(),
manualUnread: true,
},
};
notify();
scheduleSave();
}
/**
* Clear only the manual unread marker. Comment read state is preserved.
*/
export function clearTaskManualUnread(teamName: string, taskId: string): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (!prev?.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: prev.readIds,
lastUpdated: Date.now(),
},
};
notify();
scheduleSave();
}
/**
* Count unread comments for a task.
* A comment is unread if its ID is NOT in the readIds set.
@ -177,9 +218,9 @@ export function getUnreadCount(
taskId: string,
comments: { id?: string; createdAt: string }[]
): number {
if (!comments || comments.length === 0) return 0;
const key = `${teamName}/${taskId}`;
const entry = readState[key];
if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0;
if (!entry) return comments.length;
const readSet = new Set(entry.readIds);
@ -200,7 +241,7 @@ export function getUnreadCount(
// Otherwise → unread
count++;
}
return count;
return entry.manualUnread && count === 0 ? 1 : count;
}
/**
@ -272,6 +313,7 @@ async function load(): Promise<void> {
merged[k] = {
readIds: Array.from(mergedIds),
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}),
};
}
}
@ -290,6 +332,7 @@ async function load(): Promise<void> {
merged[k] = {
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}),
};
}
}

View file

@ -80,6 +80,7 @@ import type {
} from '@shared/types';
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
const ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL = false;
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
const FINISHED_TOOL_DISPLAY_MS = 1_500;
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
@ -257,14 +258,20 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer);
});
// This lightweight renderer-side poll keeps visible in-progress task badges fresh.
// It is intentionally independent from the backend log-source tracking feature flag below.
const inProgressChangePresencePollTimer = setInterval(() => {
void pollVisibleTeamInProgressChangePresence();
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
cleanupFns.push(() => {
clearInterval(inProgressChangePresencePollTimer);
});
// TODO(task-change-presence): re-enable this only after the board uses a bounded
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
// only after enough time or after opening a task popup, while still doing periodic
// summary extraction work in the background. The replacement should check visible
// tasks first, dedupe in-flight requests, keep popup/full diff requests higher
// priority, and never render "unknown" as "no_changes".
if (ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL) {
const inProgressChangePresencePollTimer = setInterval(() => {
void pollVisibleTeamInProgressChangePresence();
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
cleanupFns.push(() => {
clearInterval(inProgressChangePresencePollTimer);
});
}
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const teamLastRelevantActivityAt = new Map<string, number>();

View file

@ -116,7 +116,7 @@ function createAnthropicProviderStatus(
}
describe('team model availability Codex catalog integration', () => {
it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => {
it('uses app-server catalog models with runtime-backed labels', () => {
const providerStatus = createCodexProviderStatus(
[
{
@ -171,12 +171,62 @@ describe('team model availability Codex catalog integration', () => {
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
value: 'gpt-5.5',
label: '5.5',
badgeLabel: 'New',
availabilityStatus: 'available',
});
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
});
it('orders GPT-5.5 first after the virtual default option', () => {
const providerStatus = createCodexProviderStatus([
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: '5.4',
},
{
id: 'gpt-5.5',
launchModel: 'gpt-5.5',
displayName: 'GPT-5.5',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'high',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: '5.5',
},
{
id: 'gpt-5.2',
launchModel: 'gpt-5.2',
displayName: 'GPT-5.2',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: '5.2',
},
]);
expect(
getAvailableTeamProviderModelOptions('codex', providerStatus).map((model) => model.value)
).toEqual(['', 'gpt-5.5', 'gpt-5.4', 'gpt-5.2']);
});
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
const providerStatus = createCodexProviderStatus([
{

View file

@ -0,0 +1,374 @@
import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
import type { TeamTaskWithKanban } from '@shared/types';
export type MemberActivityPhase = 'work' | 'review';
export interface MemberActivityTimerAnchor {
timerId: string;
startedAt: string;
startedAtMs: number;
baseElapsedMs: number;
runId?: string | null;
}
interface StoredActivityTimer {
version: 1;
startedAtMs: number;
baseElapsedMs: number;
elapsedMs: number;
updatedAtMs: number;
running: boolean;
runId?: string | null;
}
const STORAGE_PREFIX = 'member-activity-timer:';
const MAX_UNOBSERVED_RUN_TRANSITION_MS = 5_000;
const timers = new Map<string, StoredActivityTimer>();
function parseIsoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizeMemberName(value: string | null | undefined): string {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function safeStorageGet(key: string): string | null {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
}
function safeStorageSet(key: string, value: string): void {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
// localStorage can be unavailable in tests or restricted browser contexts.
}
}
function storageKey(timerId: string): string {
return `${STORAGE_PREFIX}${timerId}`;
}
function isStoredTimer(value: unknown): value is StoredActivityTimer {
if (!value || typeof value !== 'object') return false;
const row = value as Partial<StoredActivityTimer>;
return (
row.version === 1 &&
typeof row.startedAtMs === 'number' &&
Number.isFinite(row.startedAtMs) &&
(row.baseElapsedMs === undefined ||
(typeof row.baseElapsedMs === 'number' && Number.isFinite(row.baseElapsedMs))) &&
typeof row.elapsedMs === 'number' &&
Number.isFinite(row.elapsedMs) &&
typeof row.updatedAtMs === 'number' &&
Number.isFinite(row.updatedAtMs) &&
typeof row.running === 'boolean' &&
(row.runId === undefined || row.runId === null || typeof row.runId === 'string')
);
}
function readStoredTimer(
timerId: string,
startedAtMs: number,
baseElapsedMs: number
): StoredActivityTimer | null {
const cached = timers.get(timerId);
if (cached?.startedAtMs === startedAtMs) {
return cached.baseElapsedMs === baseElapsedMs
? cached
: { ...cached, baseElapsedMs, elapsedMs: Math.max(baseElapsedMs, cached.elapsedMs) };
}
const raw = safeStorageGet(storageKey(timerId));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!isStoredTimer(parsed) || parsed.startedAtMs !== startedAtMs) return null;
const sanitized: StoredActivityTimer = {
version: 1,
startedAtMs: parsed.startedAtMs,
baseElapsedMs,
elapsedMs: Math.max(baseElapsedMs, parsed.elapsedMs),
updatedAtMs: Math.max(parsed.startedAtMs, parsed.updatedAtMs),
running: parsed.running,
runId: parsed.runId ?? null,
};
timers.set(timerId, sanitized);
return sanitized;
} catch {
return null;
}
}
function writeStoredTimer(timerId: string, timer: StoredActivityTimer): void {
timers.set(timerId, timer);
safeStorageSet(storageKey(timerId), JSON.stringify(timer));
}
function createInitialTimer(
startedAtMs: number,
baseElapsedMs: number,
running: boolean,
nowMs: number,
runId: string | null | undefined
): StoredActivityTimer {
if (running) {
return {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs: baseElapsedMs,
updatedAtMs: startedAtMs,
running: true,
runId,
};
}
return {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs: baseElapsedMs,
updatedAtMs: nowMs,
running: false,
runId,
};
}
function materializeElapsed(
timer: StoredActivityTimer,
nowMs: number,
runId: string | null | undefined
): number {
const baseElapsedMs = Math.max(0, timer.baseElapsedMs);
if (!timer.running) return Math.max(baseElapsedMs, timer.elapsedMs);
const rawGapMs = Math.max(0, nowMs - timer.updatedAtMs);
const sameRun = (timer.runId ?? null) === (runId ?? null);
const gapMs = sameRun ? rawGapMs : Math.min(rawGapMs, MAX_UNOBSERVED_RUN_TRANSITION_MS);
return Math.max(baseElapsedMs, timer.elapsedMs + gapMs);
}
export function createMemberActivityTimerId({
teamName,
memberName,
phase,
taskId,
startedAt,
}: {
teamName: string;
memberName: string;
phase: MemberActivityPhase;
taskId: string;
startedAt: string;
}): string {
return [teamName, normalizeMemberName(memberName), phase, taskId, startedAt].join('\u0000');
}
export function syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs = 0,
running,
runId,
nowMs = Date.now(),
}: {
timerId: string;
startedAtMs: number;
baseElapsedMs?: number;
running: boolean;
runId?: string | null;
nowMs?: number;
}): number {
const existing =
readStoredTimer(timerId, startedAtMs, baseElapsedMs) ??
createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId);
const elapsedMs = materializeElapsed(existing, nowMs, runId);
const next: StoredActivityTimer = {
version: 1,
startedAtMs,
baseElapsedMs,
elapsedMs,
updatedAtMs: nowMs,
running,
runId,
};
writeStoredTimer(timerId, next);
return elapsedMs;
}
export function readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs = 0,
running,
runId,
nowMs = Date.now(),
}: {
timerId: string;
startedAtMs: number;
baseElapsedMs?: number;
running: boolean;
runId?: string | null;
nowMs?: number;
}): number {
const timer =
readStoredTimer(timerId, startedAtMs, baseElapsedMs) ??
createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId);
return materializeElapsed(timer, nowMs, runId);
}
export function formatMemberActivityElapsed(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalMinutes < 60) {
return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
export function deriveWorkActivityTimerAnchor(
task: TeamTaskWithKanban,
params: {
teamName: string;
memberName: string;
}
): MemberActivityTimerAnchor | null {
if (!isTeamTaskActivelyWorked(task)) return null;
const intervals = Array.isArray(task.workIntervals) ? task.workIntervals : [];
let baseElapsedMs = 0;
for (let index = intervals.length - 1; index >= 0; index -= 1) {
const interval = intervals[index];
const startedAtMs = parseIsoMs(interval?.startedAt);
if (startedAtMs > 0 && !interval?.completedAt) {
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
const previous = intervals[previousIndex];
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
if (previousStartedAtMs > 0 && previousCompletedAtMs > previousStartedAtMs) {
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
}
}
return {
startedAt: interval.startedAt,
startedAtMs,
baseElapsedMs,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: interval.startedAt,
}),
};
}
}
if (intervals.length > 0) return null;
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'status_changed' && event.to === 'in_progress') {
const startedAtMs = parseIsoMs(event.timestamp);
if (startedAtMs > 0) {
return {
startedAt: event.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: event.timestamp,
}),
};
}
}
if (event.type === 'task_created' && event.status === 'in_progress') {
const startedAtMs = parseIsoMs(event.timestamp);
if (startedAtMs > 0) {
return {
startedAt: event.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'work',
taskId: task.id,
startedAt: event.timestamp,
}),
};
}
}
}
return null;
}
export function deriveReviewActivityTimerAnchor(
task: TeamTaskWithKanban,
params: {
teamName: string;
memberName: string;
}
): MemberActivityTimerAnchor | null {
const memberKey = normalizeMemberName(params.memberName);
if (!memberKey) return null;
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
if (normalizeMemberName(event.actor) !== memberKey) {
return null;
}
const startedAtMs = parseIsoMs(event.timestamp);
if (startedAtMs <= 0) return null;
return {
startedAt: event.timestamp,
startedAtMs,
baseElapsedMs: 0,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'review',
taskId: task.id,
startedAt: event.timestamp,
}),
};
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
event.type === 'task_created' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
) {
return null;
}
}
return null;
}
export function resetMemberActivityTimerStoreForTests(): void {
timers.clear();
}

View file

@ -711,6 +711,54 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
}
}
export function shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
runtimeEntry,
}: {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
spawnStatus?: MemberSpawnStatus;
spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean;
runtimeEntry?: TeamAgentRuntimeEntry;
}): boolean {
if (member.removedAt || member.status === 'terminated') {
return false;
}
if (isTeamAlive === false) {
return false;
}
if (spawnStatus === 'offline' || spawnStatus === 'error' || spawnStatus === 'skipped') {
return false;
}
if (
spawnLaunchState === 'failed_to_start' ||
spawnLaunchState === 'skipped_for_launch' ||
spawnLaunchState === 'runtime_pending_permission'
) {
return false;
}
if (
runtimeEntry?.livenessKind === 'shell_only' ||
runtimeEntry?.livenessKind === 'registered_only' ||
runtimeEntry?.livenessKind === 'stale_metadata' ||
runtimeEntry?.livenessKind === 'not_found'
) {
return false;
}
if (runtimeEntry?.alive === false && spawnStatus !== 'online') {
return false;
}
if (spawnRuntimeAlive === false && spawnStatus !== 'online') {
return false;
}
return true;
}
function isQueuedOpenCodeLaunch(
member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined,

View file

@ -84,6 +84,7 @@ const TEAM_MODEL_LABEL_OVERRIDES: Record<string, string> = {
'claude-haiku-4-5': 'Haiku 4.5',
'claude-haiku-4-5-20251001': 'Haiku 4.5',
'gpt-5.4': 'GPT-5.4',
'gpt-5.5': 'GPT-5.5',
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.3-codex': 'GPT-5.3 Codex',
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
@ -107,6 +108,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
],
codex: [
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'gpt-5.5', label: 'GPT-5.5', badgeLabel: '5.5' },
{ value: 'gpt-5.4', label: 'GPT-5.4', badgeLabel: '5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', badgeLabel: '5.4-mini' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', badgeLabel: '5.3-codex' },

View file

@ -103,6 +103,7 @@ describe('CodexLoginSessionManager', () => {
expect(fakeSession.request).toHaveBeenCalledTimes(1);
expect(openExternalMock).toHaveBeenCalledTimes(1);
expect(manager.getState().status).toBe('pending');
expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth');
});
it('cancels a login cleanly while the app-server session is still starting', async () => {
@ -135,6 +136,7 @@ describe('CodexLoginSessionManager', () => {
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
});
@ -170,6 +172,7 @@ describe('CodexLoginSessionManager', () => {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
});
});

View file

@ -40,6 +40,7 @@ const {
status: 'idle' as CodexAccountLoginStatus,
error: null as string | null,
startedAt: null as string | null,
authUrl: null as string | null,
},
},
loginStateListeners: new Set<() => void>(),
@ -857,6 +858,7 @@ describe('createCodexAccountFeature', () => {
status: 'pending',
error: null,
startedAt: '2026-04-20T12:00:00.000Z',
authUrl: 'https://chatgpt.com/auth',
});
});
@ -872,6 +874,7 @@ describe('createCodexAccountFeature', () => {
expect(pendingSnapshot.login).toMatchObject({
status: 'pending',
startedAt: '2026-04-20T12:00:00.000Z',
authUrl: 'https://chatgpt.com/auth',
});
expect(loginStartMock).toHaveBeenCalledTimes(1);
} finally {
@ -893,12 +896,14 @@ describe('createCodexAccountFeature', () => {
status: 'pending',
error: null,
startedAt: '2026-04-20T12:00:00.000Z',
authUrl: 'https://chatgpt.com/auth',
});
loginCancelMock.mockImplementation(() => {
emitLoginState({
status: 'cancelled',
error: null,
startedAt: null,
authUrl: null,
});
for (const listener of loginSettledListeners) {
listener();

View file

@ -0,0 +1,68 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator';
import {
createMemberActivityTimerId,
resetMemberActivityTimerStoreForTests,
} from '@renderer/utils/memberActivityTimer';
import type { TeamTaskWithKanban } from '@shared/types';
const task: TeamTaskWithKanban = {
id: 'task-1',
displayId: 'abc12345',
subject: 'Build feature',
status: 'in_progress',
};
describe('CurrentTaskIndicator', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
resetMemberActivityTimerStoreForTests();
globalThis.localStorage?.clear();
document.body.innerHTML = '';
});
it('renders a compact activity timer from the persisted task start anchor', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-07T09:01:05.000Z'));
const startedAt = '2026-05-07T09:00:00.000Z';
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<CurrentTaskIndicator
task={task}
borderColor="#22c55e"
activityTimer={{
startedAt,
startedAtMs: Date.parse(startedAt),
baseElapsedMs: 0,
runId: 'run-1',
timerId: createMemberActivityTimerId({
teamName: 'alpha',
memberName: 'bob',
phase: 'work',
taskId: task.id,
startedAt,
}),
}}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('1m 05s');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
const member: ResolvedTeamMember = {
name: 'alice',
@ -22,7 +22,7 @@ const storeState = {
selectedTeamData: {
members: [member],
isAlive: true,
tasks: [],
tasks: [] as TeamTaskWithKanban[],
},
selectedTeamName: 'northstar-core',
progress: null as Record<string, unknown> | null,
@ -118,7 +118,18 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
CurrentTaskIndicator: ({
task,
activityLabel,
}: {
task: TeamTaskWithKanban;
activityLabel?: string;
}) =>
React.createElement(
'span',
{ 'data-testid': 'hover-current-task' },
`${activityLabel ?? 'task'} ${task.id}`
),
}));
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
@ -307,6 +318,45 @@ describe('MemberHoverCard spawn-aware presence', () => {
});
});
it('does not show a working-on task when the member is offline', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const task: TeamTaskWithKanban = {
id: 'task-active',
subject: 'Active work',
status: 'in_progress',
};
storeState.selectedTeamData.members = [{ ...member, currentTaskId: task.id }];
storeState.selectedTeamData.tasks = [task];
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'offline',
launchState: 'confirmed_alive',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberHoverCard, {
name: 'alice',
children: React.createElement('button', { type: 'button' }, 'alice'),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="hover-current-task"]')).toBeNull();
expect(host.textContent).not.toContain('working on');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('copies launch diagnostics with the active runtime run id only for launch errors', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn().mockResolvedValue(undefined);

View file

@ -89,6 +89,24 @@ function failedSpawnStatus(reason: string): MemberSpawnStatusEntry {
};
}
function offlineSpawnStatus(): MemberSpawnStatusEntry {
return {
status: 'offline',
launchState: 'confirmed_alive',
updatedAt: '2026-04-23T10:00:00.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
};
}
function activeTask(id = 'task-active'): TeamTaskWithKanban {
return {
id,
subject: 'Active task',
status: 'in_progress',
};
}
describe('MemberList spawn-status memoization', () => {
beforeEach(() => {
vi.stubGlobal(
@ -240,6 +258,61 @@ describe('MemberList spawn-status memoization', () => {
});
});
it('does not pass active current tasks to cards while the whole team is offline', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const task = activeTask();
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: false,
taskMap: new Map([[task.id, task]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not pass active current tasks to cards for individually offline members', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const task = activeTask();
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
taskMap: new Map([[task.id, task]]),
memberSpawnStatuses: new Map([['bob', offlineSpawnStatus()]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -6,7 +6,7 @@ import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/Grap
import type { GraphNode } from '@claude-teams/agent-graph';
const previewsByMember = new Map([
const basePreviewsByMember = new Map([
[
'team-lead',
{
@ -43,6 +43,24 @@ const previewsByMember = new Map([
preview: 'pnpm test',
tone: 'warning' as const,
},
{
id: 'preview-2',
kind: 'tool_result' as const,
provider: 'opencode_runtime' as const,
timestamp: '2026-04-03T00:00:30.000Z',
title: 'Send message error',
preview: 'OpenCode tool failed without output',
tone: 'error' as const,
},
{
id: 'preview-3',
kind: 'tool_result' as const,
provider: 'opencode_runtime' as const,
timestamp: '2026-04-03T00:00:40.000Z',
title: 'Bash result',
preview: 'Tests passed',
tone: 'success' as const,
},
],
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
warnings: [],
@ -52,11 +70,12 @@ const previewsByMember = new Map([
},
],
]);
let mockedPreviewsByMember = basePreviewsByMember;
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
useGraphMemberLogPreviews: () => ({
previewsByMember,
previewsByMember: mockedPreviewsByMember,
loading: false,
error: null,
reload: vi.fn(),
@ -93,6 +112,7 @@ describe('GraphMemberLogPreviewHud', () => {
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
mockedPreviewsByMember = basePreviewsByMember;
});
afterEach(() => {
@ -141,6 +161,20 @@ describe('GraphMemberLogPreviewHud', () => {
button.textContent?.includes('pnpm test')
);
expect(row).not.toBeUndefined();
expect(row?.querySelector('.float-left')).not.toBeNull();
expect(row?.querySelector('.line-clamp-3')).toBeNull();
expect(row?.textContent).toContain('pnpm test');
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('OpenCode tool failed')
);
expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull();
const resultRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Tests passed')
);
expect(resultRow?.textContent).toContain('Bash');
expect(resultRow?.textContent).not.toContain('Bash result');
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@ -166,6 +200,83 @@ describe('GraphMemberLogPreviewHud', () => {
});
});
it('briefly highlights a newly appeared preview row', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderHud = (): void => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
};
await act(async () => {
renderHud();
await Promise.resolve();
});
const alicePreview = basePreviewsByMember.get('alice')!;
mockedPreviewsByMember = new Map(basePreviewsByMember);
mockedPreviewsByMember.set('alice', {
...alicePreview,
items: [
{
id: 'preview-new',
kind: 'text' as const,
provider: 'claude_transcript' as const,
timestamp: '2026-04-03T00:01:00.000Z',
title: 'Assistant',
preview: 'new compact log',
tone: 'neutral' as const,
},
...alicePreview.items,
],
});
await act(async () => {
renderHud();
await Promise.resolve();
});
const newRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('new compact log')
);
expect(newRow?.className).toContain('border-sky-300/70');
await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});
expect(newRow?.className).not.toContain('border-sky-300/70');
act(() => {
root.unmount();
});
});
it('renders lead log previews and opens the lead profile logs tab', async () => {
const leadNode: GraphNode = {
id: 'lead:alpha-team',

View file

@ -11,7 +11,10 @@ import {
validateStableSlotLayout,
} from '../../../../packages/agent-graph/src/layout/stableSlots';
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants';
import {
KANBAN_ZONE,
TASK_PILL,
} from '../../../../packages/agent-graph/src/constants/canvas-constants';
import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
import {
STABLE_SLOT_GEOMETRY,
@ -171,7 +174,10 @@ describe('stable slot layout planner', () => {
expect(frame).toBeDefined();
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top);
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
const expectedKanbanTopInset =
ACTIVITY_LANE.headerHeight + 4 - (KANBAN_ZONE.headerHeight - TASK_PILL.height / 2);
expect(frame?.kanbanBandRect.top).toBe(frame!.boardBandRect.top + expectedKanbanTopInset);
expect(frame?.kanbanBandRect.bottom).toBeLessThanOrEqual(frame!.boardBandRect.bottom);
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);

View file

@ -0,0 +1,284 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createMemberActivityTimerId,
deriveReviewActivityTimerAnchor,
deriveWorkActivityTimerAnchor,
formatMemberActivityElapsed,
readMemberActivityTimerElapsed,
resetMemberActivityTimerStoreForTests,
syncMemberActivityTimer,
} from '@renderer/utils/memberActivityTimer';
import type { TeamTaskWithKanban } from '@shared/types';
const baseTask: TeamTaskWithKanban = {
id: 'task-1',
displayId: 'abc12345',
subject: 'Build feature',
status: 'in_progress',
createdAt: '2026-05-07T09:00:00.000Z',
reviewState: 'none',
};
describe('memberActivityTimer', () => {
afterEach(() => {
vi.useRealTimers();
resetMemberActivityTimerStoreForTests();
globalThis.localStorage?.clear();
});
it('anchors work timers to the active work interval', () => {
const task: TeamTaskWithKanban = {
...baseTask,
workIntervals: [
{
startedAt: '2026-05-07T09:10:00.000Z',
completedAt: '2026-05-07T09:15:00.000Z',
},
{ startedAt: '2026-05-07T09:20:00.000Z' },
],
};
const anchor = deriveWorkActivityTimerAnchor(task, {
teamName: 'alpha',
memberName: 'bob',
});
expect(anchor?.startedAt).toBe('2026-05-07T09:20:00.000Z');
expect(anchor?.baseElapsedMs).toBe(300_000);
expect(anchor?.timerId).toContain('task-1');
});
it('adds completed work intervals to the active timer elapsed value', () => {
const task: TeamTaskWithKanban = {
...baseTask,
workIntervals: [
{
startedAt: '2026-05-07T09:10:00.000Z',
completedAt: '2026-05-07T09:15:00.000Z',
},
{ startedAt: '2026-05-07T09:20:00.000Z' },
],
};
const anchor = deriveWorkActivityTimerAnchor(task, {
teamName: 'alpha',
memberName: 'bob',
});
expect(anchor).not.toBeNull();
expect(
readMemberActivityTimerElapsed({
timerId: anchor!.timerId,
startedAtMs: anchor!.startedAtMs,
baseElapsedMs: anchor!.baseElapsedMs,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:21:00.000Z'),
})
).toBe(360_000);
});
it('does not invent a work timer when task start evidence is missing', () => {
expect(
deriveWorkActivityTimerAnchor(baseTask, {
teamName: 'alpha',
memberName: 'bob',
})
).toBeNull();
});
it('treats closed work intervals without an active interval as paused', () => {
const task: TeamTaskWithKanban = {
...baseTask,
workIntervals: [
{
startedAt: '2026-05-07T09:10:00.000Z',
completedAt: '2026-05-07T09:15:00.000Z',
},
],
historyEvents: [
{
id: 'evt-1',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-07T09:10:00.000Z',
},
],
};
expect(
deriveWorkActivityTimerAnchor(task, {
teamName: 'alpha',
memberName: 'bob',
})
).toBeNull();
});
it('anchors review timers only after the reviewer actually starts review', () => {
const assignedOnly: TeamTaskWithKanban = {
...baseTask,
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'alice',
historyEvents: [
{
id: 'evt-1',
type: 'review_requested',
from: 'none',
to: 'review',
reviewer: 'alice',
timestamp: '2026-05-07T09:30:00.000Z',
},
],
};
expect(
deriveReviewActivityTimerAnchor(assignedOnly, {
teamName: 'alpha',
memberName: 'alice',
})
).toBeNull();
const started: TeamTaskWithKanban = {
...assignedOnly,
historyEvents: [
...(assignedOnly.historyEvents ?? []),
{
id: 'evt-2',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'alice',
timestamp: '2026-05-07T09:35:00.000Z',
},
],
};
expect(
deriveReviewActivityTimerAnchor(started, {
teamName: 'alpha',
memberName: 'alice',
})?.startedAt
).toBe('2026-05-07T09:35:00.000Z');
});
it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => {
const timerId = createMemberActivityTimerId({
teamName: 'alpha',
memberName: 'bob',
phase: 'work',
taskId: 'task-1',
startedAt: '2026-05-07T09:00:00.000Z',
});
const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z');
syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:01:00.000Z'),
});
expect(
readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:02:00.000Z'),
})
).toBe(120_000);
syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: false,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:02:00.000Z'),
});
expect(
readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: false,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:05:00.000Z'),
})
).toBe(120_000);
syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:05:00.000Z'),
});
expect(
readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:06:00.000Z'),
})
).toBe(180_000);
});
it('caps elapsed time across unobserved runtime run transitions', () => {
const timerId = createMemberActivityTimerId({
teamName: 'alpha',
memberName: 'bob',
phase: 'work',
taskId: 'task-1',
startedAt: '2026-05-07T09:00:00.000Z',
});
const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z');
syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-1',
nowMs: Date.parse('2026-05-07T09:01:00.000Z'),
});
syncMemberActivityTimer({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-2',
nowMs: Date.parse('2026-05-07T10:00:00.000Z'),
});
expect(
readMemberActivityTimerElapsed({
timerId,
startedAtMs,
baseElapsedMs: 0,
running: true,
runId: 'run-2',
nowMs: Date.parse('2026-05-07T10:00:00.000Z'),
})
).toBe(65_000);
});
it('formats seconds, minutes, and hours compactly', () => {
expect(formatMemberActivityElapsed(9_000)).toBe('9s');
expect(formatMemberActivityElapsed(65_000)).toBe('1m 05s');
expect(formatMemberActivityElapsed(3_780_000)).toBe('1h 03m');
});
});

View file

@ -8,6 +8,7 @@ import {
getMemberRuntimeAdvisoryTitle,
getMemberRuntimeAdvisoryTone,
isOpenCodeRelaunchActionable,
shouldDisplayMemberCurrentTask,
} from '@renderer/utils/memberHelpers';
import type { ResolvedTeamMember } from '@shared/types';
@ -27,6 +28,73 @@ const member: ResolvedTeamMember = {
};
describe('memberHelpers spawn-aware presence', () => {
it('does not display current task labels for offline or terminal launch states', () => {
expect(
shouldDisplayMemberCurrentTask({
member: { ...member, currentTaskId: 'task-1' },
isTeamAlive: false,
})
).toBe(false);
expect(
shouldDisplayMemberCurrentTask({
member: { ...member, currentTaskId: 'task-1' },
isTeamAlive: true,
spawnStatus: 'offline',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: false,
})
).toBe(false);
expect(
shouldDisplayMemberCurrentTask({
member: { ...member, currentTaskId: 'task-1' },
isTeamAlive: true,
spawnStatus: 'error',
spawnLaunchState: 'failed_to_start',
})
).toBe(false);
});
it('does not display current task labels for runtime entries without a live agent runtime', () => {
expect(
shouldDisplayMemberCurrentTask({
member: { ...member, currentTaskId: 'task-1' },
isTeamAlive: true,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
providerId: 'opencode',
livenessKind: 'stale_metadata',
updatedAt: '2026-04-24T12:00:00.000Z',
},
})
).toBe(false);
});
it('keeps current task labels for confirmed online members', () => {
expect(
shouldDisplayMemberCurrentTask({
member: { ...member, currentTaskId: 'task-1' },
isTeamAlive: true,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'gemini',
livenessKind: 'confirmed_bootstrap',
updatedAt: '2026-04-24T12:00:00.000Z',
},
})
).toBe(true);
});
it('shows process-online teammates as online with a green dot', () => {
expect(
getSpawnAwarePresenceLabel(