chore(runtime): bump runtime lock to 0.0.22
This commit is contained in:
parent
8caa962dec
commit
9a1b01b2b6
44 changed files with 3191 additions and 185 deletions
6
.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml
Normal file
6
.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml
Normal 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
BIN
graph-log-preview-smoke.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
|
|
@ -128,6 +128,7 @@ const SLOT_GEOMETRY = {
|
||||||
const PROCESS_RAIL_NODE_GAP = 42;
|
const PROCESS_RAIL_NODE_GAP = 42;
|
||||||
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
||||||
const GEOMETRY_EPSILON = 0.001;
|
const GEOMETRY_EPSILON = 0.001;
|
||||||
|
const FEED_HEADER_BOTTOM_GAP = 4;
|
||||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
||||||
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
||||||
|
|
@ -372,7 +373,7 @@ function buildOwnerFootprint(args: {
|
||||||
const boardBandHeight = Math.max(
|
const boardBandHeight = Math.max(
|
||||||
activityColumnHeight,
|
activityColumnHeight,
|
||||||
logColumnHeight,
|
logColumnHeight,
|
||||||
SLOT_GEOMETRY.kanbanBandHeight
|
SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth })
|
||||||
);
|
);
|
||||||
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
||||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||||
|
|
@ -1362,9 +1363,10 @@ function buildSlotFrameAtOwnerAnchor(
|
||||||
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
|
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
|
||||||
? SLOT_GEOMETRY.boardColumnGap
|
? SLOT_GEOMETRY.boardColumnGap
|
||||||
: 0;
|
: 0;
|
||||||
|
const kanbanBandTopInset = getKanbanBandTopInset(footprint);
|
||||||
const kanbanBandRect = createRect(
|
const kanbanBandRect = createRect(
|
||||||
logColumnRect.right + feedToKanbanGap,
|
logColumnRect.right + feedToKanbanGap,
|
||||||
boardBandRect.top,
|
boardBandRect.top + kanbanBandTopInset,
|
||||||
footprint.kanbanBandWidth,
|
footprint.kanbanBandWidth,
|
||||||
footprint.kanbanBandHeight
|
footprint.kanbanBandHeight
|
||||||
);
|
);
|
||||||
|
|
@ -1390,6 +1392,19 @@ function getOwnerAnchorTopOffset(): number {
|
||||||
return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2;
|
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[] {
|
function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] {
|
||||||
const candidates: GraphOwnerSlotAssignment[] = [];
|
const candidates: GraphOwnerSlotAssignment[] = [];
|
||||||
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {
|
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"sourceRef": "v0.0.21",
|
"sourceRef": "v0.0.22",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.22.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type {
|
||||||
|
|
||||||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||||
|
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||||
|
|
||||||
interface StableRectLike {
|
interface StableRectLike {
|
||||||
left: number;
|
left: number;
|
||||||
|
|
@ -75,7 +76,12 @@ function formatRelativeTime(timestamp: string): string {
|
||||||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||||
const className = 'size-3.5 shrink-0';
|
const className = 'size-3.5 shrink-0';
|
||||||
const title = item.title.trim().toLowerCase();
|
const title = item.title.trim().toLowerCase();
|
||||||
|
if (item.tone === 'error') {
|
||||||
|
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
|
title.includes('message') ||
|
||||||
|
title.includes('comment') ||
|
||||||
title === 'send message' ||
|
title === 'send message' ||
|
||||||
title === 'message sent' ||
|
title === 'message sent' ||
|
||||||
title === 'add comment' ||
|
title === 'add comment' ||
|
||||||
|
|
@ -83,9 +89,6 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||||
) {
|
) {
|
||||||
return <MessageSquareText className={`${className} text-sky-300`} />;
|
return <MessageSquareText className={`${className} text-sky-300`} />;
|
||||||
}
|
}
|
||||||
if (item.tone === 'error') {
|
|
||||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
|
||||||
}
|
|
||||||
if (item.kind === 'tool_result') {
|
if (item.kind === 'tool_result') {
|
||||||
return <CheckCircle2 className={`${className} text-emerald-300`} />;
|
return <CheckCircle2 className={`${className} text-emerald-300`} />;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +109,14 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading:
|
||||||
return 'No recent logs';
|
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 {
|
function setShellHidden(shell: HTMLDivElement): void {
|
||||||
shell.style.opacity = '0';
|
shell.style.opacity = '0';
|
||||||
shell.style.pointerEvents = 'none';
|
shell.style.pointerEvents = 'none';
|
||||||
|
|
@ -125,7 +136,12 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||||
const visibleKeyRef = useRef('');
|
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 [visibleMemberNames, setVisibleMemberNames] = useState<string[]>([]);
|
||||||
|
const [highlightedItemIds, setHighlightedItemIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set()
|
||||||
|
);
|
||||||
const { teamData } = useGraphActivityContext(teamName);
|
const { teamData } = useGraphActivityContext(teamName);
|
||||||
const members = teamData?.members ?? [];
|
const members = teamData?.members ?? [];
|
||||||
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
|
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
|
||||||
|
|
@ -155,6 +171,69 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
[onOpenMemberProfile]
|
[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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!enabled || ownerNodes.length === 0) {
|
if (!enabled || ownerNodes.length === 0) {
|
||||||
for (const shell of shellRefs.current.values()) {
|
for (const shell of shellRefs.current.values()) {
|
||||||
|
|
@ -285,29 +364,57 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
}, [enabled, forwardWheelToGraph, ownerNodes]);
|
}, [enabled, forwardWheelToGraph, ownerNodes]);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(memberName: string, item: MemberLogPreviewItem) => (
|
(memberName: string, item: MemberLogPreviewItem) => {
|
||||||
<button
|
const relativeTime = formatRelativeTime(item.timestamp);
|
||||||
key={item.id}
|
const displayTitle = compactDisplayTitle(item);
|
||||||
type="button"
|
const previewText = item.preview || item.sourceLabel || 'Log event';
|
||||||
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)]"
|
const titleText = relativeTime
|
||||||
onClick={() => openLogs(memberName)}
|
? `${item.title} ${relativeTime} ${previewText}`
|
||||||
>
|
: `${item.title} ${previewText}`;
|
||||||
<span
|
const isHighlighted = highlightedItemIds.has(item.id);
|
||||||
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"
|
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="float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5 align-top"
|
||||||
<span className="align-baseline text-[11px] font-medium leading-4 text-slate-200">
|
aria-hidden="true"
|
||||||
{item.title}
|
>
|
||||||
</span>{' '}
|
{itemIcon(item)}
|
||||||
<span className="align-baseline text-[9px] leading-4 text-slate-500">
|
</span>
|
||||||
{formatRelativeTime(item.timestamp)}
|
<span
|
||||||
</span>{' '}
|
className="inline-flex h-5 items-center align-top"
|
||||||
<span className="align-baseline">{item.preview || item.sourceLabel || 'Log event'}</span>
|
style={{ position: 'relative', top: '-3px' }}
|
||||||
</button>
|
>
|
||||||
),
|
<span className="text-[11px] font-medium leading-none text-slate-200">
|
||||||
[openLogs]
|
{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) {
|
if (!enabled || ownerNodes.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export interface CodexLoginStateDto {
|
||||||
status: CodexAccountLoginStatus;
|
status: CodexAccountLoginStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
|
authUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexRuntimeContextDto {
|
export interface CodexRuntimeContextDto {
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: loginState.status === 'failed' ? loginState.error : null,
|
error: loginState.status === 'failed' ? loginState.error : null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
};
|
};
|
||||||
private pendingStartToken: symbol | null = null;
|
private pendingStartToken: symbol | null = null;
|
||||||
private activeSession: {
|
private activeSession: {
|
||||||
|
|
@ -71,6 +72,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'starting',
|
status: 'starting',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -135,6 +137,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
|
authUrl: authUrl.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await shell.openExternal(authUrl.toString());
|
await shell.openExternal(authUrl.toString());
|
||||||
|
|
@ -158,6 +161,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
|
authUrl: this.state.authUrl,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +174,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
return;
|
return;
|
||||||
|
|
@ -180,6 +185,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +213,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +228,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +242,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,12 +264,14 @@ export class CodexLoginSessionManager {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: notification.error ?? 'ChatGPT login failed.',
|
error: notification.error ?? 'ChatGPT login failed.',
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
|
authUrl: this.state.authUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,6 +292,7 @@ export class CodexLoginSessionManager {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
|
authUrl: this.state.authUrl,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,76 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
expect(result.items[1]?.preview).toBe('older answer');
|
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', () => {
|
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||||
const hugeOutput = 'x'.repeat(10_000);
|
const hugeOutput = 'x'.repeat(10_000);
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
|
|
@ -95,7 +165,7 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
|
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
kind: 'tool_result',
|
kind: 'tool_result',
|
||||||
title: 'Tool error',
|
title: 'Bash error',
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
laneId: 'secondary:opencode:alice',
|
laneId: 'secondary:opencode:alice',
|
||||||
});
|
});
|
||||||
|
|
@ -166,15 +236,64 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
title: 'Message sent',
|
title: 'Message sent',
|
||||||
preview: 'Message sent to team-lead - #abc done',
|
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({
|
expect(result.items[1]).toMatchObject({
|
||||||
kind: 'tool_use',
|
kind: 'tool_use',
|
||||||
title: 'Send message',
|
title: 'Send message',
|
||||||
preview: 'to team-lead: #abc done',
|
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({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'claude_transcript',
|
provider: 'claude_transcript',
|
||||||
maxItems: 3,
|
maxItems: 3,
|
||||||
|
|
@ -211,12 +330,119 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
kind: 'tool_result',
|
kind: 'tool_result',
|
||||||
title: 'Comment added',
|
title: 'Comment',
|
||||||
preview: 'Comment by tom on #task-799: Done with UI review',
|
preview: 'Comment by tom on #task-799: Done with UI review',
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(result.items)).not.toContain('"comment"');
|
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', () => {
|
it('formats plain board tool results through the paired tool_use context', () => {
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'claude_transcript',
|
provider: 'claude_transcript',
|
||||||
|
|
@ -257,6 +483,47 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
preview: 'Completed #abc12345',
|
preview: 'Completed #abc12345',
|
||||||
toolName: 'mcp__agent-teams__task_complete',
|
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({
|
expect(result.items[1]).toMatchObject({
|
||||||
kind: 'tool_use',
|
kind: 'tool_use',
|
||||||
title: 'Complete task',
|
title: 'Complete task',
|
||||||
|
|
@ -284,7 +551,7 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
task: {
|
task: {
|
||||||
id: 'abc12345-0000-0000-0000-000000000000',
|
id: 'abc12345-0000-0000-0000-000000000000',
|
||||||
displayId: 'abc12345',
|
displayId: 'abc12345',
|
||||||
title: 'Fix preview alignment',
|
subject: 'Fix preview alignment',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
owner: 'tom',
|
owner: 'tom',
|
||||||
},
|
},
|
||||||
|
|
@ -304,6 +571,182 @@ describe('memberLogPreviewExtractor', () => {
|
||||||
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
|
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', () => {
|
it('keeps orphan tool results visible because graph preview is diagnostic', () => {
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'claude_transcript',
|
provider: 'claude_transcript',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface MemberLogPreviewParsedMessage {
|
||||||
role?: string;
|
role?: string;
|
||||||
timestamp: Date | string;
|
timestamp: Date | string;
|
||||||
content: string | MemberLogPreviewContentBlock[];
|
content: string | MemberLogPreviewContentBlock[];
|
||||||
|
isMeta?: boolean;
|
||||||
toolCalls?: readonly {
|
toolCalls?: readonly {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -57,6 +58,8 @@ interface Candidate {
|
||||||
timestampMs: number;
|
timestampMs: number;
|
||||||
order: number;
|
order: number;
|
||||||
textTruncated: boolean;
|
textTruncated: boolean;
|
||||||
|
toolUseKey?: string;
|
||||||
|
supersededByResult?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNKNOWN_TIMESTAMP_MS = 0;
|
const UNKNOWN_TIMESTAMP_MS = 0;
|
||||||
|
|
@ -139,6 +142,19 @@ function compactWhitespace(value: string): string {
|
||||||
return stripAngleTags(value).replace(/\s+/g, ' ').trim();
|
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 {
|
function looksLikeJsonPayload(value: string): boolean {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
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 {
|
function formatToolTitle(toolName: string): string {
|
||||||
const canonical = canonicalToolName(toolName);
|
const canonical = canonicalToolName(toolName);
|
||||||
if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message';
|
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_complete') return 'Complete task';
|
||||||
if (canonical === 'task_add_comment') return 'Add comment';
|
if (canonical === 'task_add_comment') return 'Add comment';
|
||||||
if (canonical === 'task_get_comment') return 'Read comment';
|
if (canonical === 'task_get_comment') return 'Read comment';
|
||||||
if (canonical === 'task_get') return 'Read task';
|
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_start') return 'Start task';
|
||||||
if (canonical === 'task_set_status') return 'Set status';
|
if (canonical === 'task_set_status') return 'Set status';
|
||||||
if (canonical === 'task_set_owner') return 'Set owner';
|
if (canonical === 'task_set_owner') return 'Set owner';
|
||||||
if (canonical === 'task_set_clarification') return 'Set clarification';
|
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_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_request') return 'Request review';
|
||||||
if (canonical === 'review_start') return 'Start 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 === 'runtime_bootstrap_checkin') return 'Runtime check-in';
|
||||||
if (canonical === 'member_briefing') return 'Member briefing';
|
if (canonical === 'member_briefing') return 'Member briefing';
|
||||||
if (canonical === 'task_add') return 'Add task';
|
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(
|
function stringField(
|
||||||
|
|
@ -326,7 +407,8 @@ function taskRefFromPayload(
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortTaskSummary(task: Record<string, unknown> | undefined): string | null {
|
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 status = stringField(task, 'status');
|
||||||
const owner = stringField(task, 'owner');
|
const owner = stringField(task, 'owner');
|
||||||
const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter(
|
const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter(
|
||||||
|
|
@ -373,6 +455,64 @@ function formatTaskCommentPayload(
|
||||||
return `Comment: ${commentText}`;
|
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(
|
function formatTaskToolPayload(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
canonicalToolNameValue: string | null,
|
canonicalToolNameValue: string | null,
|
||||||
|
|
@ -393,13 +533,42 @@ function formatTaskToolPayload(
|
||||||
const filename =
|
const filename =
|
||||||
stringField(payload, 'filename') ??
|
stringField(payload, '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, 'fileName') ??
|
||||||
|
stringField(fallbackInput ?? undefined, 'path') ??
|
||||||
|
stringField(fallbackInput ?? undefined, 'filePath');
|
||||||
|
|
||||||
if (canonical === 'task_add_comment') {
|
if (canonical === 'task_add_comment') {
|
||||||
const text = formatTaskCommentPayload(payload, fallbackInput);
|
const text = formatTaskCommentPayload(payload, fallbackInput);
|
||||||
return text ? { title: 'Comment added', text } : null;
|
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') {
|
if (canonical === 'task_start') {
|
||||||
return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null;
|
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}` };
|
if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` };
|
||||||
return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null;
|
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') {
|
if (canonical === 'review_request') {
|
||||||
const reviewer =
|
const reviewer =
|
||||||
stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer');
|
stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer');
|
||||||
|
|
@ -438,6 +620,21 @@ function formatTaskToolPayload(
|
||||||
if (canonical === 'review_start') {
|
if (canonical === 'review_start') {
|
||||||
return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null;
|
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) {
|
if (taskRef && status) {
|
||||||
return { title: 'Task update', text: `Task ${taskRef} ${status}` };
|
return { title: 'Task update', text: `Task ${taskRef} ${status}` };
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +707,22 @@ function formatMessageSendPayload(payload: Record<string, unknown>): string | nu
|
||||||
return null;
|
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 {
|
function formatMessageSendResultFromInput(payload: Record<string, unknown>): string | null {
|
||||||
const target = stringField(payload, 'to') ?? stringField(payload, 'target');
|
const target = stringField(payload, 'to') ?? stringField(payload, 'target');
|
||||||
const summary =
|
const summary =
|
||||||
|
|
@ -536,6 +749,27 @@ function formatMessageSendInputPayload(payload: Record<string, unknown>): string
|
||||||
return null;
|
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(
|
function formatPlainToolResultStatus(
|
||||||
value: string,
|
value: string,
|
||||||
toolContext: ToolUseContext | undefined
|
toolContext: ToolUseContext | undefined
|
||||||
|
|
@ -552,6 +786,10 @@ function formatPlainToolResultStatus(
|
||||||
const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null;
|
const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null;
|
||||||
return text ? { title: 'Message sent', text } : 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 (
|
return (
|
||||||
formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ??
|
formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ??
|
||||||
formatRuntimePayload({}, toolContext.canonicalName, fallbackInput)
|
formatRuntimePayload({}, toolContext.canonicalName, fallbackInput)
|
||||||
|
|
@ -568,6 +806,13 @@ function formatTaskToolInputPayload(
|
||||||
const owner = stringField(payload, 'owner');
|
const owner = stringField(payload, 'owner');
|
||||||
const clarification = stringField(payload, 'clarification');
|
const clarification = stringField(payload, 'clarification');
|
||||||
const reviewer = stringField(payload, 'reviewer');
|
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 (canonicalToolNameValue === 'task_add_comment') {
|
||||||
if (taskRef && text) return `on ${taskRef}: ${text}`;
|
if (taskRef && text) return `on ${taskRef}: ${text}`;
|
||||||
|
|
@ -575,6 +820,10 @@ function formatTaskToolInputPayload(
|
||||||
if (text) return text;
|
if (text) return text;
|
||||||
return null;
|
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 (canonicalToolNameValue === 'task_set_status') {
|
||||||
if (taskRef && status) return `${taskRef} -> ${status}`;
|
if (taskRef && status) return `${taskRef} -> ${status}`;
|
||||||
}
|
}
|
||||||
|
|
@ -587,6 +836,21 @@ function formatTaskToolInputPayload(
|
||||||
if (canonicalToolNameValue === 'review_request') {
|
if (canonicalToolNameValue === 'review_request') {
|
||||||
if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`;
|
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;
|
if (taskRef) return taskRef;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -616,13 +880,24 @@ function formatKnownPayloadPreview(
|
||||||
if (runtimeText) {
|
if (runtimeText) {
|
||||||
return 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) {
|
if (messageText) {
|
||||||
return { title: 'Message sent', text: messageText };
|
return { title: 'Message sent', text: messageText };
|
||||||
}
|
}
|
||||||
const commentText = formatTaskCommentPayload(payload);
|
const commentText = formatTaskCommentPayload(payload);
|
||||||
if (commentText) {
|
if (commentText) {
|
||||||
return { title: 'Comment added', text: commentText };
|
return { title: 'Comment', text: commentText };
|
||||||
}
|
}
|
||||||
const taskText = formatTaskStatusPayload(payload, fallbackInput);
|
const taskText = formatTaskStatusPayload(payload, fallbackInput);
|
||||||
if (taskText) {
|
if (taskText) {
|
||||||
|
|
@ -646,6 +921,10 @@ function previewUnknownValue(
|
||||||
if (plainStatus) {
|
if (plainStatus) {
|
||||||
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
|
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);
|
return truncatePreview(value, limit);
|
||||||
}
|
}
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
|
@ -694,6 +973,13 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
|
||||||
return truncatePreview(formatted, limit);
|
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);
|
const payload = recordFromUnknown(value);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const taskFormatted = formatTaskToolInputPayload(canonical, payload);
|
const taskFormatted = formatTaskToolInputPayload(canonical, payload);
|
||||||
|
|
@ -722,6 +1008,118 @@ function extractTextPreview(
|
||||||
return preview.preview.length > 0 ? preview : null;
|
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(
|
function isToolUseBlock(
|
||||||
block: MemberLogPreviewContentBlock
|
block: MemberLogPreviewContentBlock
|
||||||
): block is Extract<MemberLogPreviewContentBlock, { type: 'tool_use' }> {
|
): block is Extract<MemberLogPreviewContentBlock, { type: 'tool_use' }> {
|
||||||
|
|
@ -792,6 +1190,13 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string {
|
||||||
return message.role ?? message.type ?? '';
|
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: {
|
function buildItemId(input: {
|
||||||
provider: MemberLogStreamProvider;
|
provider: MemberLogStreamProvider;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
|
@ -824,6 +1229,8 @@ function buildCandidate(input: {
|
||||||
laneId?: string;
|
laneId?: string;
|
||||||
token: string;
|
token: string;
|
||||||
textTruncated: boolean;
|
textTruncated: boolean;
|
||||||
|
toolUseKey?: string;
|
||||||
|
supersededByResult?: boolean;
|
||||||
}): Candidate {
|
}): Candidate {
|
||||||
const timestamp = timestampIso(input.message.timestamp);
|
const timestamp = timestampIso(input.message.timestamp);
|
||||||
const messageId = input.message.uuid ?? `message-${input.messageIndex}`;
|
const messageId = input.message.uuid ?? `message-${input.messageIndex}`;
|
||||||
|
|
@ -850,6 +1257,8 @@ function buildCandidate(input: {
|
||||||
timestampMs: timestampMs(input.message.timestamp),
|
timestampMs: timestampMs(input.message.timestamp),
|
||||||
order: input.messageIndex * 1_000 + input.blockIndex,
|
order: input.messageIndex * 1_000 + input.blockIndex,
|
||||||
textTruncated: input.textTruncated,
|
textTruncated: input.textTruncated,
|
||||||
|
...(input.toolUseKey ? { toolUseKey: input.toolUseKey } : {}),
|
||||||
|
...(input.supersededByResult ? { supersededByResult: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -873,6 +1282,11 @@ function collectToolUseCandidates(input: {
|
||||||
if (seen.has(id)) return;
|
if (seen.has(id)) return;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const preview = previewToolInputValue(tool.name, tool.input, input.textLimit);
|
const preview = previewToolInputValue(tool.name, tool.input, input.textLimit);
|
||||||
|
const toolUseKey = buildToolUseKey({
|
||||||
|
provider: input.provider,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
toolUseId: id,
|
||||||
|
});
|
||||||
candidates.push(
|
candidates.push(
|
||||||
buildCandidate({
|
buildCandidate({
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
|
|
@ -890,6 +1304,8 @@ function collectToolUseCandidates(input: {
|
||||||
laneId: input.laneId,
|
laneId: input.laneId,
|
||||||
token: id,
|
token: id,
|
||||||
textTruncated: preview.truncated,
|
textTruncated: preview.truncated,
|
||||||
|
toolUseKey,
|
||||||
|
supersededByResult: isToolUseSupersededBySuccessResult(tool.name),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -933,6 +1349,11 @@ function collectToolResultCandidates(input: {
|
||||||
if (seen.has(id)) return;
|
if (seen.has(id)) return;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const toolContext = input.toolUseContexts.get(id);
|
const toolContext = input.toolUseContexts.get(id);
|
||||||
|
const toolUseKey = buildToolUseKey({
|
||||||
|
provider: input.provider,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
toolUseId: id,
|
||||||
|
});
|
||||||
const preview = previewUnknownValue(
|
const preview = previewUnknownValue(
|
||||||
result.content,
|
result.content,
|
||||||
input.textLimit,
|
input.textLimit,
|
||||||
|
|
@ -940,6 +1361,10 @@ function collectToolResultCandidates(input: {
|
||||||
toolContext
|
toolContext
|
||||||
);
|
);
|
||||||
const isError = result.isError === true || preview.title === 'Tool error';
|
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(
|
candidates.push(
|
||||||
buildCandidate({
|
buildCandidate({
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
|
|
@ -948,7 +1373,7 @@ function collectToolResultCandidates(input: {
|
||||||
messageIndex: input.messageIndex,
|
messageIndex: input.messageIndex,
|
||||||
blockIndex,
|
blockIndex,
|
||||||
kind: 'tool_result',
|
kind: 'tool_result',
|
||||||
title: isError ? 'Tool error' : (preview.title ?? 'Tool result'),
|
title,
|
||||||
preview: preview.preview,
|
preview: preview.preview,
|
||||||
tone: isError ? 'error' : 'success',
|
tone: isError ? 'error' : 'success',
|
||||||
toolName: toolContext?.name,
|
toolName: toolContext?.name,
|
||||||
|
|
@ -957,6 +1382,7 @@ function collectToolResultCandidates(input: {
|
||||||
laneId: input.laneId,
|
laneId: input.laneId,
|
||||||
token: id,
|
token: id,
|
||||||
textTruncated: preview.truncated,
|
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) => {
|
sorted.sort((left, right) => {
|
||||||
const byTime = right.timestampMs - left.timestampMs;
|
const byTime = right.timestampMs - left.timestampMs;
|
||||||
if (byTime !== 0) return byTime;
|
if (byTime !== 0) return byTime;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
|
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
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') {
|
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
|
const usageHint = codex.localActiveChatgptAccountPresent
|
||||||
|
|
@ -731,6 +732,8 @@ const InstalledBanner = ({
|
||||||
provider.connection?.codex?.launchAllowed !== true &&
|
provider.connection?.codex?.launchAllowed !== true &&
|
||||||
provider.connection?.codex?.login.status !== 'starting' &&
|
provider.connection?.codex?.login.status !== 'starting' &&
|
||||||
provider.connection?.codex?.login.status !== 'pending';
|
provider.connection?.codex?.login.status !== 'pending';
|
||||||
|
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
|
||||||
|
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
|
||||||
const disconnectAction = getProviderDisconnectAction(provider);
|
const disconnectAction = getProviderDisconnectAction(provider);
|
||||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||||
|
|
@ -897,20 +900,33 @@ const InstalledBanner = ({
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="min-w-0 flex-1">{codexDashboardHint}</span>
|
<span className="min-w-0 flex-1">{codexDashboardHint}</span>
|
||||||
{codexNeedsReconnect ? (
|
{showCodexLoginActions ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<CodexLoginLinkCopyButton
|
||||||
onClick={onCodexReconnect}
|
authUrl={codexLoginAuthUrl}
|
||||||
disabled={codexReconnectBusy || actionDisabled}
|
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"
|
size="xs"
|
||||||
style={{
|
/>
|
||||||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
<button
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
type="button"
|
||||||
color: '#fbbf24',
|
onClick={() => {
|
||||||
}}
|
if (codexLoginAuthUrl) {
|
||||||
>
|
void api.openExternal(codexLoginAuthUrl);
|
||||||
Reconnect ChatGPT
|
return;
|
||||||
</button>
|
}
|
||||||
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
56
src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
Normal file
56
src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
|
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react';
|
import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -715,6 +716,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession;
|
Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession;
|
||||||
const codexLoginPending =
|
const codexLoginPending =
|
||||||
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
||||||
|
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
|
||||||
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
||||||
const configuredAuthMode: CliProviderAuthMode | undefined =
|
const configuredAuthMode: CliProviderAuthMode | undefined =
|
||||||
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
||||||
|
|
@ -1389,14 +1391,31 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{codexLoginPending ? (
|
{codexLoginPending ? (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<CodexLoginLinkCopyButton
|
||||||
variant="outline"
|
authUrl={codexLoginAuthUrl}
|
||||||
disabled={codexActionBusy}
|
disabled={codexActionBusy}
|
||||||
onClick={() => void handleCodexCancelLogin()}
|
/>
|
||||||
>
|
{codexLoginAuthUrl ? (
|
||||||
Cancel login
|
<Button
|
||||||
</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 ? (
|
) : codexHasActiveChatgptSession ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1407,15 +1426,21 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
Disconnect account
|
Disconnect account
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<CodexLoginLinkCopyButton
|
||||||
variant="outline"
|
authUrl={codexLoginAuthUrl}
|
||||||
disabled={codexActionBusy}
|
disabled={codexActionBusy}
|
||||||
onClick={() => void handleCodexStartLogin()}
|
/>
|
||||||
>
|
<Button
|
||||||
<Link2 className="mr-1 size-3.5" />
|
size="sm"
|
||||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
variant="outline"
|
||||||
</Button>
|
disabled={codexActionBusy}
|
||||||
|
onClick={() => void handleCodexStartLogin()}
|
||||||
|
>
|
||||||
|
<Link2 className="mr-1 size-3.5" />
|
||||||
|
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { markTaskUnread } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -283,6 +284,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
setRenamingTaskKey(null);
|
setRenamingTaskKey(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleMarkTaskUnread = useCallback((teamName: string, taskId: string): void => {
|
||||||
|
markTaskUnread(teamName, taskId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTask = useCallback(
|
const handleDeleteTask = useCallback(
|
||||||
async (teamName: string, taskId: string): Promise<void> => {
|
async (teamName: string, taskId: string): Promise<void> => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
|
|
@ -548,6 +553,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
isArchived={false}
|
isArchived={false}
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
|
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
onDelete={() => handleDeleteTask(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)}
|
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
|
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
|
|
@ -726,6 +733,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
onToggleArchive={() =>
|
onToggleArchive={() =>
|
||||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||||
}
|
}
|
||||||
|
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
|
|
@ -832,6 +840,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
onToggleArchive={() =>
|
onToggleArchive={() =>
|
||||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||||
}
|
}
|
||||||
|
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
|
import { clearTaskManualUnread } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
|
@ -157,6 +158,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isRenaming) {
|
if (!isRenaming) {
|
||||||
|
clearTaskManualUnread(task.teamName, task.id);
|
||||||
openGlobalTaskDetail(task.teamName, task.id);
|
openGlobalTaskDetail(task.teamName, task.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@renderer/components/ui/context-menu';
|
} 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';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface TaskContextMenuProps {
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
onTogglePin: () => void;
|
onTogglePin: () => void;
|
||||||
onToggleArchive: () => void;
|
onToggleArchive: () => void;
|
||||||
|
onMarkUnread: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
|
||||||
isArchived,
|
isArchived,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
|
onMarkUnread,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
children,
|
children,
|
||||||
|
|
@ -55,6 +57,11 @@ export const TaskContextMenu = ({
|
||||||
<span>Rename</span>
|
<span>Rename</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem onSelect={onMarkUnread}>
|
||||||
|
<Mail className="size-3.5 shrink-0" />
|
||||||
|
<span>Mark as unread</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
<ContextMenuItem onSelect={onToggleArchive}>
|
<ContextMenuItem onSelect={onToggleArchive}>
|
||||||
|
|
@ -74,10 +81,7 @@ export const TaskContextMenu = ({
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
|
||||||
onSelect={onDelete}
|
|
||||||
className="text-red-400 focus:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5 shrink-0" />
|
<Trash2 className="size-3.5 shrink-0" />
|
||||||
<span>Delete task</span>
|
<span>Delete task</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
return (
|
return (
|
||||||
<MemberList
|
<MemberList
|
||||||
{...props}
|
{...props}
|
||||||
|
teamName={teamName}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
memberSpawnStatuses={memberSpawnStatusMap}
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
memberRuntimeEntries={memberRuntimeMap}
|
memberRuntimeEntries={memberRuntimeMap}
|
||||||
|
|
|
||||||
101
src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx
Normal file
101
src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||||
|
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||||
import {
|
import {
|
||||||
clearInheritedMemberModelsUnavailableForProvider,
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
|
|
@ -95,6 +96,7 @@ import {
|
||||||
} from './memberModelScope';
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import {
|
import {
|
||||||
buildReusableProviderPrepareModelResults,
|
buildReusableProviderPrepareModelResults,
|
||||||
|
|
@ -155,7 +157,6 @@ const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskU
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
Project,
|
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamFastMode,
|
TeamFastMode,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
|
|
@ -402,7 +403,7 @@ export const CreateTeamDialog = ({
|
||||||
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
||||||
|
|
||||||
// ── Transient UI state (NOT persisted) ───────────────────────────────
|
// ── Transient UI state (NOT persisted) ───────────────────────────────
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
@ -709,6 +710,19 @@ export const CreateTeamDialog = ({
|
||||||
});
|
});
|
||||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
prepareRequestSeqRef.current += 1;
|
prepareRequestSeqRef.current += 1;
|
||||||
|
|
@ -948,36 +962,11 @@ export const CreateTeamDialog = ({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const nextProjects = (await api.getProjects()).filter(
|
const nextProjects = await loadProjectPathProjects({ defaultProjectPath });
|
||||||
(project) => !isEphemeralProjectPath(project.path)
|
|
||||||
);
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
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);
|
setProjects(nextProjects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
|
|
@ -1552,6 +1541,12 @@ export const CreateTeamDialog = ({
|
||||||
}),
|
}),
|
||||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||||
);
|
);
|
||||||
|
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
|
||||||
|
effectiveCliStatus,
|
||||||
|
selectedProviderIds: selectedMemberProviders,
|
||||||
|
prepareMessage: effectivePrepare.message,
|
||||||
|
prepareChecks,
|
||||||
|
});
|
||||||
const canOpenExistingTeam =
|
const canOpenExistingTeam =
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||||
|
|
||||||
|
|
@ -2117,8 +2112,8 @@ export const CreateTeamDialog = ({
|
||||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning, index) => (
|
||||||
<p key={warning} className="text-[11px] text-sky-300">
|
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
|
||||||
{warning}
|
{warning}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2152,9 +2147,9 @@ export const CreateTeamDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||||
<div className="mt-1 space-y-0.5 pl-6">
|
<div className="mt-1 space-y-0.5 pl-6">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning, index) => (
|
||||||
<p
|
<p
|
||||||
key={warning}
|
key={`${index}:${warning}`}
|
||||||
className="text-[11px]"
|
className="text-[11px]"
|
||||||
style={{ color: 'var(--warning-text)' }}
|
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)]">
|
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
|
||||||
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
|
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
|
||||||
</p>
|
</p>
|
||||||
|
{showCodexReconnectPrompt ? (
|
||||||
|
<div className="pl-6">
|
||||||
|
<CodexReconnectPrompt
|
||||||
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
|
reconnectBusy={codexAccount.loading}
|
||||||
|
onReconnect={handleCodexReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||||
|
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
|
|
@ -100,6 +101,7 @@ import {
|
||||||
} from './memberModelScope';
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import {
|
import {
|
||||||
buildReusableProviderPrepareModelResults,
|
buildReusableProviderPrepareModelResults,
|
||||||
|
|
@ -153,7 +155,6 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
Project,
|
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
Schedule,
|
Schedule,
|
||||||
ScheduleLaunchConfig,
|
ScheduleLaunchConfig,
|
||||||
|
|
@ -404,7 +405,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const chipDraft = useChipDraftPersistence(
|
const chipDraft = useChipDraftPersistence(
|
||||||
`launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips`
|
`launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips`
|
||||||
);
|
);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||||
const [localError, setLocalError] = 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]);
|
}, [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
|
// Schedule store actions
|
||||||
const createSchedule = useStore((s) => s.createSchedule);
|
const createSchedule = useStore((s) => s.createSchedule);
|
||||||
const updateSchedule = useStore((s) => s.updateSchedule);
|
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 repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
|
||||||
|
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -1589,30 +1604,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const apiProjects = (await api.getProjects()).filter(
|
const nextProjects = await loadProjectPathProjects({
|
||||||
(project) => !isEphemeralProjectPath(project.path)
|
defaultProjectPath,
|
||||||
);
|
repositoryGroups,
|
||||||
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const pathSet = new Set(apiProjects.map((p) => p.path));
|
setProjects(nextProjects);
|
||||||
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]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
|
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
|
||||||
|
|
@ -1625,10 +1623,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, repositoryGroups]);
|
}, [open, repositoryGroups, defaultProjectPath]);
|
||||||
|
|
||||||
// Pre-select defaultProjectPath (launch mode) or first project
|
// Pre-select defaultProjectPath (launch mode) or first project
|
||||||
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
|
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
|
||||||
|
|
@ -1920,6 +1917,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}),
|
}),
|
||||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||||
);
|
);
|
||||||
|
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
|
||||||
|
effectiveCliStatus,
|
||||||
|
selectedProviderIds: selectedMemberProviders,
|
||||||
|
prepareMessage: effectivePrepare.message,
|
||||||
|
prepareChecks,
|
||||||
|
});
|
||||||
const launchInFlight = useStore((s) =>
|
const launchInFlight = useStore((s) =>
|
||||||
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
||||||
);
|
);
|
||||||
|
|
@ -2819,8 +2822,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning, index) => (
|
||||||
<p key={warning} className="text-[11px] text-sky-300">
|
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
|
||||||
{warning}
|
{warning}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2858,9 +2861,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
) : null}
|
) : null}
|
||||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||||
<div className="mt-1 space-y-0.5 pl-6">
|
<div className="mt-1 space-y-0.5 pl-6">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning, index) => (
|
||||||
<p
|
<p
|
||||||
key={warning}
|
key={`${index}:${warning}`}
|
||||||
className="text-[11px]"
|
className="text-[11px]"
|
||||||
style={{ color: 'var(--warning-text)' }}
|
style={{ color: 'var(--warning-text)' }}
|
||||||
>
|
>
|
||||||
|
|
@ -2889,6 +2892,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{showCodexReconnectPrompt ? (
|
||||||
|
<div className="pl-6">
|
||||||
|
<CodexReconnectPrompt
|
||||||
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
|
reconnectBusy={codexAccount.loading}
|
||||||
|
onReconnect={handleCodexReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
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 { cn } from '@renderer/lib/utils';
|
||||||
import { Check, FolderOpen } from 'lucide-react';
|
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 {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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';
|
export type CwdMode = 'project' | 'custom';
|
||||||
|
|
||||||
interface ProjectPathSelectorProps {
|
interface ProjectPathSelectorProps {
|
||||||
|
|
@ -54,7 +103,7 @@ interface ProjectPathSelectorProps {
|
||||||
onSelectedProjectPathChange: (path: string) => void;
|
onSelectedProjectPathChange: (path: string) => void;
|
||||||
customCwd: string;
|
customCwd: string;
|
||||||
onCustomCwdChange: (cwd: string) => void;
|
onCustomCwdChange: (cwd: string) => void;
|
||||||
projects: Project[];
|
projects: ProjectPathProject[];
|
||||||
projectsLoading: boolean;
|
projectsLoading: boolean;
|
||||||
projectsError: string | null;
|
projectsError: string | null;
|
||||||
fieldError?: string | null;
|
fieldError?: string | null;
|
||||||
|
|
@ -123,6 +172,12 @@ export const ProjectPathSelector = ({
|
||||||
searchPlaceholder="Search project by name or path"
|
searchPlaceholder="Search project by name or path"
|
||||||
emptyMessage="Nothing found"
|
emptyMessage="Nothing found"
|
||||||
disabled={projectsLoading || projectOptions.length === 0}
|
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) => (
|
renderOption={(option, isSelected, query) => (
|
||||||
<>
|
<>
|
||||||
<Check
|
<Check
|
||||||
|
|
@ -131,6 +186,7 @@ export const ProjectPathSelector = ({
|
||||||
isSelected ? 'opacity-100' : 'opacity-0'
|
isSelected ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-[var(--color-text)]">
|
<p className="truncate font-medium text-[var(--color-text)]">
|
||||||
{renderHighlightedText(option.label, query)}
|
{renderHighlightedText(option.label, query)}
|
||||||
|
|
|
||||||
|
|
@ -619,9 +619,9 @@ export const ProvisioningProviderStatusList = ({
|
||||||
</div>
|
</div>
|
||||||
{visibleDetails.length > 0 ? (
|
{visibleDetails.length > 0 ? (
|
||||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||||
{visibleDetails.map((detail) => (
|
{visibleDetails.map((detail, index) => (
|
||||||
<p
|
<p
|
||||||
key={detail}
|
key={`${check.providerId}:${index}:${detail}`}
|
||||||
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
|
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
|
||||||
>
|
>
|
||||||
{detail}
|
{detail}
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex flex-col items-center justify-center gap-0.5">
|
<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 ? (
|
{sourceBadgeLabel ? (
|
||||||
<span
|
<span
|
||||||
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,25 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||||
|
|
||||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||||
|
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
|
||||||
import type { Project } from '@shared/types';
|
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 {
|
return {
|
||||||
value: project.path,
|
value: project.path,
|
||||||
label: project.name,
|
label: project.name,
|
||||||
description: project.path,
|
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.
|
* This keeps combobox item values unique even when scanner sources overlap.
|
||||||
*/
|
*/
|
||||||
export function buildProjectPathOptions(
|
export function buildProjectPathOptions(
|
||||||
projects: Project[],
|
projects: ProjectPathProject[],
|
||||||
preferredPath?: string
|
preferredPath?: string
|
||||||
): ComboboxOption[] {
|
): ComboboxOption[] {
|
||||||
const options: ComboboxOption[] = [];
|
const options: ComboboxOption[] = [];
|
||||||
|
|
|
||||||
140
src/renderer/components/team/dialogs/projectPathProjects.ts
Normal file
140
src/renderer/components/team/dialogs/projectPathProjects.ts
Normal 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] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { memo } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||||
|
import {
|
||||||
|
formatMemberActivityElapsed,
|
||||||
|
readMemberActivityTimerElapsed,
|
||||||
|
syncMemberActivityTimer,
|
||||||
|
} from '@renderer/utils/memberActivityTimer';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
|
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface CurrentTaskIndicatorProps {
|
interface CurrentTaskIndicatorProps {
|
||||||
|
|
@ -10,9 +16,71 @@ interface CurrentTaskIndicatorProps {
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
maxSubjectLength?: number;
|
maxSubjectLength?: number;
|
||||||
activityLabel?: string;
|
activityLabel?: string;
|
||||||
|
activityTimer?: MemberActivityTimerAnchor | null;
|
||||||
|
isTimerRunning?: boolean;
|
||||||
onOpenTask?: () => void;
|
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.
|
* Inline indicator showing a spinning loader + "working on" + task label button.
|
||||||
* Shared between MemberCard and MemberHoverCard.
|
* Shared between MemberCard and MemberHoverCard.
|
||||||
|
|
@ -23,8 +91,11 @@ export const CurrentTaskIndicator = memo(
|
||||||
borderColor,
|
borderColor,
|
||||||
maxSubjectLength,
|
maxSubjectLength,
|
||||||
activityLabel = 'working on',
|
activityLabel = 'working on',
|
||||||
|
activityTimer,
|
||||||
|
isTimerRunning = true,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||||
|
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
|
||||||
const subjectText =
|
const subjectText =
|
||||||
typeof maxSubjectLength === 'number' &&
|
typeof maxSubjectLength === 'number' &&
|
||||||
maxSubjectLength > 0 &&
|
maxSubjectLength > 0 &&
|
||||||
|
|
@ -54,6 +125,14 @@ export const CurrentTaskIndicator = memo(
|
||||||
>
|
>
|
||||||
{formatTaskDisplayLabel(task)} {subjectText}
|
{formatTaskDisplayLabel(task)} {subjectText}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||||
|
|
||||||
|
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -54,6 +55,10 @@ interface MemberCardProps {
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
currentTask?: TeamTaskWithKanban | null;
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
reviewTask?: TeamTaskWithKanban | null;
|
reviewTask?: TeamTaskWithKanban | null;
|
||||||
|
currentTaskTimer?: MemberActivityTimerAnchor | null;
|
||||||
|
reviewTaskTimer?: MemberActivityTimerAnchor | null;
|
||||||
|
currentTaskTimerRunning?: boolean;
|
||||||
|
reviewTaskTimerRunning?: boolean;
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
|
@ -132,6 +137,10 @@ export const MemberCard = memo(function MemberCard({
|
||||||
leadActivity,
|
leadActivity,
|
||||||
currentTask,
|
currentTask,
|
||||||
reviewTask,
|
reviewTask,
|
||||||
|
currentTaskTimer,
|
||||||
|
reviewTaskTimer,
|
||||||
|
currentTaskTimerRunning = isTeamAlive !== false,
|
||||||
|
reviewTaskTimerRunning = isTeamAlive !== false,
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
isRemoved,
|
isRemoved,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -433,6 +442,8 @@ export const MemberCard = memo(function MemberCard({
|
||||||
task={currentTask}
|
task={currentTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
activityLabel="working on"
|
activityLabel="working on"
|
||||||
|
activityTimer={currentTaskTimer}
|
||||||
|
isTimerRunning={currentTaskTimerRunning}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -441,6 +452,8 @@ export const MemberCard = memo(function MemberCard({
|
||||||
task={reviewTask}
|
task={reviewTask}
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
activityLabel="reviewing"
|
activityLabel="reviewing"
|
||||||
|
activityTimer={reviewTaskTimer}
|
||||||
|
isTimerRunning={reviewTaskTimerRunning}
|
||||||
onOpenTask={onOpenReviewTask}
|
onOpenTask={onOpenReviewTask}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,15 @@ import {
|
||||||
buildMemberAvatarMap,
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
|
shouldDisplayMemberCurrentTask,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
getMemberLaunchDiagnosticsErrorMessage,
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
hasMemberLaunchDiagnosticsDetails,
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
@ -42,7 +43,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||||
|
|
||||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberHoverCardProps {
|
interface MemberHoverCardProps {
|
||||||
/** The member name to look up */
|
/** The member name to look up */
|
||||||
|
|
@ -131,7 +132,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
|
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
: 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 =
|
const presentationMember =
|
||||||
member.currentTaskId && !currentTask
|
member.currentTaskId && !currentTask
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
@ -9,6 +14,7 @@ import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
|
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -22,6 +28,7 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
|
teamName?: string;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||||
taskMap?: Map<string, TeamTaskWithKanban>;
|
taskMap?: Map<string, TeamTaskWithKanban>;
|
||||||
|
|
@ -101,6 +108,45 @@ function areTaskStatusCountsMapsEquivalent(
|
||||||
return true;
|
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(
|
function areMemberTaskMapsEquivalent(
|
||||||
left: Map<string, TeamTaskWithKanban> | undefined,
|
left: Map<string, TeamTaskWithKanban> | undefined,
|
||||||
right: Map<string, TeamTaskWithKanban> | undefined
|
right: Map<string, TeamTaskWithKanban> | undefined
|
||||||
|
|
@ -118,7 +164,9 @@ function areMemberTaskMapsEquivalent(
|
||||||
leftTask.status !== rightTask.status ||
|
leftTask.status !== rightTask.status ||
|
||||||
leftTask.reviewer !== rightTask.reviewer ||
|
leftTask.reviewer !== rightTask.reviewer ||
|
||||||
leftTask.reviewState !== rightTask.reviewState ||
|
leftTask.reviewState !== rightTask.reviewState ||
|
||||||
leftTask.kanbanColumn !== rightTask.kanbanColumn
|
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
|
||||||
|
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
|
||||||
|
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -243,6 +291,7 @@ function areMemberListPropsEqual(
|
||||||
next: Readonly<MemberListProps>
|
next: Readonly<MemberListProps>
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
|
prev.teamName === next.teamName &&
|
||||||
areResolvedMembersEquivalent(prev.members, next.members) &&
|
areResolvedMembersEquivalent(prev.members, next.members) &&
|
||||||
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
|
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
|
||||||
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
|
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
|
||||||
|
|
@ -270,6 +319,10 @@ interface MemberCardRowProps {
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
currentTask: TeamTaskWithKanban | null;
|
currentTask: TeamTaskWithKanban | null;
|
||||||
reviewTask: TeamTaskWithKanban | null;
|
reviewTask: TeamTaskWithKanban | null;
|
||||||
|
currentTaskTimer: MemberActivityTimerAnchor | null;
|
||||||
|
reviewTaskTimer: MemberActivityTimerAnchor | null;
|
||||||
|
currentTaskTimerRunning: boolean;
|
||||||
|
reviewTaskTimerRunning: boolean;
|
||||||
awaitingReply: boolean;
|
awaitingReply: boolean;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
runtimeSummary?: string;
|
runtimeSummary?: string;
|
||||||
|
|
@ -299,6 +352,10 @@ const MemberCardRow = memo(function MemberCardRow({
|
||||||
memberColor,
|
memberColor,
|
||||||
currentTask,
|
currentTask,
|
||||||
reviewTask,
|
reviewTask,
|
||||||
|
currentTaskTimer,
|
||||||
|
reviewTaskTimer,
|
||||||
|
currentTaskTimerRunning,
|
||||||
|
reviewTaskTimerRunning,
|
||||||
awaitingReply,
|
awaitingReply,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
|
|
@ -346,6 +403,10 @@ const MemberCardRow = memo(function MemberCardRow({
|
||||||
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
||||||
currentTask={currentTask}
|
currentTask={currentTask}
|
||||||
reviewTask={reviewTask}
|
reviewTask={reviewTask}
|
||||||
|
currentTaskTimer={currentTaskTimer}
|
||||||
|
reviewTaskTimer={reviewTaskTimer}
|
||||||
|
currentTaskTimerRunning={currentTaskTimerRunning}
|
||||||
|
reviewTaskTimerRunning={reviewTaskTimerRunning}
|
||||||
isAwaitingReply={awaitingReply}
|
isAwaitingReply={awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
runtimeSummary={runtimeSummary}
|
runtimeSummary={runtimeSummary}
|
||||||
|
|
@ -370,6 +431,7 @@ const MemberCardRow = memo(function MemberCardRow({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MemberList = memo(function MemberList({
|
export const MemberList = memo(function MemberList({
|
||||||
|
teamName = '__unknown_team__',
|
||||||
members,
|
members,
|
||||||
memberTaskCounts,
|
memberTaskCounts,
|
||||||
taskMap,
|
taskMap,
|
||||||
|
|
@ -434,6 +496,124 @@ export const MemberList = memo(function MemberList({
|
||||||
return result;
|
return result;
|
||||||
}, [taskMap]);
|
}, [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(
|
const buildRuntimeSummary = useCallback(
|
||||||
(
|
(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
|
|
@ -457,16 +637,44 @@ export const MemberList = memo(function MemberList({
|
||||||
<div ref={containerRef} className="flex flex-col gap-1">
|
<div ref={containerRef} className="flex flex-col gap-1">
|
||||||
<div className={gridClass}>
|
<div className={gridClass}>
|
||||||
{activeMembers.map((member) => {
|
{activeMembers.map((member) => {
|
||||||
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
|
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||||
const currentTaskCandidate =
|
const currentTaskCandidate =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
const currentTask =
|
||||||
? currentTaskCandidate
|
isDisplayableCurrentTask(currentTaskCandidate) &&
|
||||||
: null;
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member,
|
||||||
|
isTeamAlive,
|
||||||
|
spawnStatus: spawnEntry?.status,
|
||||||
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
|
})
|
||||||
|
? currentTaskCandidate
|
||||||
|
: null;
|
||||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
const reviewTask =
|
const reviewTask =
|
||||||
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
|
||||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
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 (
|
return (
|
||||||
<MemberCardRow
|
<MemberCardRow
|
||||||
key={member.name}
|
key={member.name}
|
||||||
|
|
@ -475,6 +683,10 @@ export const MemberList = memo(function MemberList({
|
||||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||||
currentTask={currentTask}
|
currentTask={currentTask}
|
||||||
reviewTask={reviewTask}
|
reviewTask={reviewTask}
|
||||||
|
currentTaskTimer={currentTaskTimer}
|
||||||
|
reviewTaskTimer={reviewTaskTimer}
|
||||||
|
currentTaskTimerRunning={activityTimerRunning}
|
||||||
|
reviewTaskTimerRunning={activityTimerRunning}
|
||||||
awaitingReply={
|
awaitingReply={
|
||||||
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
|
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +728,10 @@ export const MemberList = memo(function MemberList({
|
||||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||||
currentTask={null}
|
currentTask={null}
|
||||||
reviewTask={null}
|
reviewTask={null}
|
||||||
|
currentTaskTimer={null}
|
||||||
|
reviewTaskTimer={null}
|
||||||
|
currentTaskTimerRunning={false}
|
||||||
|
reviewTaskTimerRunning={false}
|
||||||
awaitingReply={false}
|
awaitingReply={false}
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}
|
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
interface TaskReadEntry {
|
interface TaskReadEntry {
|
||||||
readIds: string[];
|
readIds: string[];
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
|
manualUnread?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
|
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.
|
* Mark specific comment IDs as read for a given team/task.
|
||||||
*/
|
*/
|
||||||
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
|
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
|
||||||
if (commentIds.length === 0) return;
|
|
||||||
const key = `${teamName}/${taskId}`;
|
const key = `${teamName}/${taskId}`;
|
||||||
const prev = cache[key];
|
const prev = cache[key];
|
||||||
|
if (commentIds.length === 0) {
|
||||||
|
if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const prevSet = new Set(prev?.readIds ?? []);
|
const prevSet = new Set(prev?.readIds ?? []);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const id of commentIds) {
|
for (const id of commentIds) {
|
||||||
|
|
@ -127,7 +131,7 @@ export function markCommentsRead(teamName: string, taskId: string, commentIds: s
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!changed) return;
|
if (!changed && !prev?.manualUnread) return;
|
||||||
cache = {
|
cache = {
|
||||||
...cache,
|
...cache,
|
||||||
[key]: {
|
[key]: {
|
||||||
|
|
@ -148,7 +152,7 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
|
||||||
const prev = cache[key];
|
const prev = cache[key];
|
||||||
// Update lastUpdated to at least this timestamp (for legacy migration support)
|
// Update lastUpdated to at least this timestamp (for legacy migration support)
|
||||||
const prevLastUpdated = prev?.lastUpdated ?? 0;
|
const prevLastUpdated = prev?.lastUpdated ?? 0;
|
||||||
if (latestTimestamp <= prevLastUpdated && prev) return;
|
if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return;
|
||||||
cache = {
|
cache = {
|
||||||
...cache,
|
...cache,
|
||||||
[key]: {
|
[key]: {
|
||||||
|
|
@ -160,6 +164,43 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
|
||||||
scheduleSave();
|
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.
|
* Count unread comments for a task.
|
||||||
* A comment is unread if its ID is NOT in the readIds set.
|
* A comment is unread if its ID is NOT in the readIds set.
|
||||||
|
|
@ -177,9 +218,9 @@ export function getUnreadCount(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
comments: { id?: string; createdAt: string }[]
|
comments: { id?: string; createdAt: string }[]
|
||||||
): number {
|
): number {
|
||||||
if (!comments || comments.length === 0) return 0;
|
|
||||||
const key = `${teamName}/${taskId}`;
|
const key = `${teamName}/${taskId}`;
|
||||||
const entry = readState[key];
|
const entry = readState[key];
|
||||||
|
if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0;
|
||||||
if (!entry) return comments.length;
|
if (!entry) return comments.length;
|
||||||
|
|
||||||
const readSet = new Set(entry.readIds);
|
const readSet = new Set(entry.readIds);
|
||||||
|
|
@ -200,7 +241,7 @@ export function getUnreadCount(
|
||||||
// Otherwise → unread
|
// Otherwise → unread
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
return count;
|
return entry.manualUnread && count === 0 ? 1 : count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -272,6 +313,7 @@ async function load(): Promise<void> {
|
||||||
merged[k] = {
|
merged[k] = {
|
||||||
readIds: Array.from(mergedIds),
|
readIds: Array.from(mergedIds),
|
||||||
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
|
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
|
||||||
|
...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +332,7 @@ async function load(): Promise<void> {
|
||||||
merged[k] = {
|
merged[k] = {
|
||||||
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
|
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
|
||||||
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
|
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
|
||||||
|
...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
|
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 IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||||
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
||||||
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
||||||
|
|
@ -257,14 +258,20 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||||
});
|
});
|
||||||
// This lightweight renderer-side poll keeps visible in-progress task badges fresh.
|
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
||||||
// It is intentionally independent from the backend log-source tracking feature flag below.
|
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||||
const inProgressChangePresencePollTimer = setInterval(() => {
|
// only after enough time or after opening a task popup, while still doing periodic
|
||||||
void pollVisibleTeamInProgressChangePresence();
|
// summary extraction work in the background. The replacement should check visible
|
||||||
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
// tasks first, dedupe in-flight requests, keep popup/full diff requests higher
|
||||||
cleanupFns.push(() => {
|
// priority, and never render "unknown" as "no_changes".
|
||||||
clearInterval(inProgressChangePresencePollTimer);
|
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 pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const teamLastRelevantActivityAt = new Map<string, number>();
|
const teamLastRelevantActivityAt = new Map<string, number>();
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ function createAnthropicProviderStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('team model availability Codex catalog integration', () => {
|
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(
|
const providerStatus = createCodexProviderStatus(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -171,12 +171,62 @@ describe('team model availability Codex catalog integration', () => {
|
||||||
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||||
value: 'gpt-5.5',
|
value: 'gpt-5.5',
|
||||||
label: '5.5',
|
label: '5.5',
|
||||||
badgeLabel: 'New',
|
|
||||||
availabilityStatus: 'available',
|
availabilityStatus: 'available',
|
||||||
});
|
});
|
||||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
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', () => {
|
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
||||||
const providerStatus = createCodexProviderStatus([
|
const providerStatus = createCodexProviderStatus([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
374
src/renderer/utils/memberActivityTimer.ts
Normal file
374
src/renderer/utils/memberActivityTimer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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(
|
function isQueuedOpenCodeLaunch(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ const TEAM_MODEL_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
'claude-haiku-4-5': 'Haiku 4.5',
|
'claude-haiku-4-5': 'Haiku 4.5',
|
||||||
'claude-haiku-4-5-20251001': 'Haiku 4.5',
|
'claude-haiku-4-5-20251001': 'Haiku 4.5',
|
||||||
'gpt-5.4': 'GPT-5.4',
|
'gpt-5.4': 'GPT-5.4',
|
||||||
|
'gpt-5.5': 'GPT-5.5',
|
||||||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||||||
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
||||||
|
|
@ -107,6 +108,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
|
||||||
],
|
],
|
||||||
codex: [
|
codex: [
|
||||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
{ 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', label: 'GPT-5.4', badgeLabel: '5.4' },
|
||||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', badgeLabel: '5.4-mini' },
|
{ 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' },
|
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', badgeLabel: '5.3-codex' },
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ describe('CodexLoginSessionManager', () => {
|
||||||
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
||||||
expect(openExternalMock).toHaveBeenCalledTimes(1);
|
expect(openExternalMock).toHaveBeenCalledTimes(1);
|
||||||
expect(manager.getState().status).toBe('pending');
|
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 () => {
|
it('cancels a login cleanly while the app-server session is still starting', async () => {
|
||||||
|
|
@ -135,6 +136,7 @@ describe('CodexLoginSessionManager', () => {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,6 +172,7 @@ describe('CodexLoginSessionManager', () => {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const {
|
||||||
status: 'idle' as CodexAccountLoginStatus,
|
status: 'idle' as CodexAccountLoginStatus,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
startedAt: null as string | null,
|
startedAt: null as string | null,
|
||||||
|
authUrl: null as string | null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
loginStateListeners: new Set<() => void>(),
|
loginStateListeners: new Set<() => void>(),
|
||||||
|
|
@ -857,6 +858,7 @@ describe('createCodexAccountFeature', () => {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: '2026-04-20T12:00:00.000Z',
|
startedAt: '2026-04-20T12:00:00.000Z',
|
||||||
|
authUrl: 'https://chatgpt.com/auth',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -872,6 +874,7 @@ describe('createCodexAccountFeature', () => {
|
||||||
expect(pendingSnapshot.login).toMatchObject({
|
expect(pendingSnapshot.login).toMatchObject({
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
startedAt: '2026-04-20T12:00:00.000Z',
|
startedAt: '2026-04-20T12:00:00.000Z',
|
||||||
|
authUrl: 'https://chatgpt.com/auth',
|
||||||
});
|
});
|
||||||
expect(loginStartMock).toHaveBeenCalledTimes(1);
|
expect(loginStartMock).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -893,12 +896,14 @@ describe('createCodexAccountFeature', () => {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: '2026-04-20T12:00:00.000Z',
|
startedAt: '2026-04-20T12:00:00.000Z',
|
||||||
|
authUrl: 'https://chatgpt.com/auth',
|
||||||
});
|
});
|
||||||
loginCancelMock.mockImplementation(() => {
|
loginCancelMock.mockImplementation(() => {
|
||||||
emitLoginState({
|
emitLoginState({
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
});
|
});
|
||||||
for (const listener of loginSettledListeners) {
|
for (const listener of loginSettledListeners) {
|
||||||
listener();
|
listener();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 = {
|
const member: ResolvedTeamMember = {
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
|
|
@ -22,7 +22,7 @@ const storeState = {
|
||||||
selectedTeamData: {
|
selectedTeamData: {
|
||||||
members: [member],
|
members: [member],
|
||||||
isAlive: true,
|
isAlive: true,
|
||||||
tasks: [],
|
tasks: [] as TeamTaskWithKanban[],
|
||||||
},
|
},
|
||||||
selectedTeamName: 'northstar-core',
|
selectedTeamName: 'northstar-core',
|
||||||
progress: null as Record<string, unknown> | null,
|
progress: null as Record<string, unknown> | null,
|
||||||
|
|
@ -118,7 +118,18 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
|
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';
|
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 () => {
|
it('copies launch diagnostics with the active runtime run id only for launch errors', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
describe('MemberList spawn-status memoization', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal(
|
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 () => {
|
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/Grap
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
const previewsByMember = new Map([
|
const basePreviewsByMember = new Map([
|
||||||
[
|
[
|
||||||
'team-lead',
|
'team-lead',
|
||||||
{
|
{
|
||||||
|
|
@ -43,6 +43,24 @@ const previewsByMember = new Map([
|
||||||
preview: 'pnpm test',
|
preview: 'pnpm test',
|
||||||
tone: 'warning' as const,
|
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 }],
|
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
|
@ -52,11 +70,12 @@ const previewsByMember = new Map([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
let mockedPreviewsByMember = basePreviewsByMember;
|
||||||
|
|
||||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||||
useGraphMemberLogPreviews: () => ({
|
useGraphMemberLogPreviews: () => ({
|
||||||
previewsByMember,
|
previewsByMember: mockedPreviewsByMember,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
reload: vi.fn(),
|
reload: vi.fn(),
|
||||||
|
|
@ -93,6 +112,7 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||||
|
mockedPreviewsByMember = basePreviewsByMember;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -141,6 +161,20 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
button.textContent?.includes('pnpm test')
|
button.textContent?.includes('pnpm test')
|
||||||
);
|
);
|
||||||
expect(row).not.toBeUndefined();
|
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 () => {
|
await act(async () => {
|
||||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
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 () => {
|
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||||
const leadNode: GraphNode = {
|
const leadNode: GraphNode = {
|
||||||
id: 'lead:alpha-team',
|
id: 'lead:alpha-team',
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import {
|
||||||
validateStableSlotLayout,
|
validateStableSlotLayout,
|
||||||
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
||||||
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
|
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 { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
|
||||||
import {
|
import {
|
||||||
STABLE_SLOT_GEOMETRY,
|
STABLE_SLOT_GEOMETRY,
|
||||||
|
|
@ -171,7 +174,10 @@ describe('stable slot layout planner', () => {
|
||||||
expect(frame).toBeDefined();
|
expect(frame).toBeDefined();
|
||||||
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
||||||
expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.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?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||||
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);
|
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);
|
||||||
|
|
|
||||||
284
test/renderer/utils/memberActivityTimer.test.ts
Normal file
284
test/renderer/utils/memberActivityTimer.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
getMemberRuntimeAdvisoryTitle,
|
getMemberRuntimeAdvisoryTitle,
|
||||||
getMemberRuntimeAdvisoryTone,
|
getMemberRuntimeAdvisoryTone,
|
||||||
isOpenCodeRelaunchActionable,
|
isOpenCodeRelaunchActionable,
|
||||||
|
shouldDisplayMemberCurrentTask,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -27,6 +28,73 @@ const member: ResolvedTeamMember = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('memberHelpers spawn-aware presence', () => {
|
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', () => {
|
it('shows process-online teammates as online with a green dot', () => {
|
||||||
expect(
|
expect(
|
||||||
getSpawnAwarePresenceLabel(
|
getSpawnAwarePresenceLabel(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue