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_FOOTPRINT = 28;
|
||||
const GEOMETRY_EPSILON = 0.001;
|
||||
const FEED_HEADER_BOTTOM_GAP = 4;
|
||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
||||
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
||||
|
|
@ -372,7 +373,7 @@ function buildOwnerFootprint(args: {
|
|||
const boardBandHeight = Math.max(
|
||||
activityColumnHeight,
|
||||
logColumnHeight,
|
||||
SLOT_GEOMETRY.kanbanBandHeight
|
||||
SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth })
|
||||
);
|
||||
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||
|
|
@ -1362,9 +1363,10 @@ function buildSlotFrameAtOwnerAnchor(
|
|||
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
|
||||
? SLOT_GEOMETRY.boardColumnGap
|
||||
: 0;
|
||||
const kanbanBandTopInset = getKanbanBandTopInset(footprint);
|
||||
const kanbanBandRect = createRect(
|
||||
logColumnRect.right + feedToKanbanGap,
|
||||
boardBandRect.top,
|
||||
boardBandRect.top + kanbanBandTopInset,
|
||||
footprint.kanbanBandWidth,
|
||||
footprint.kanbanBandHeight
|
||||
);
|
||||
|
|
@ -1390,6 +1392,19 @@ function getOwnerAnchorTopOffset(): number {
|
|||
return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2;
|
||||
}
|
||||
|
||||
function getKanbanBandTopInset(args: {
|
||||
activityColumnWidth: number;
|
||||
logColumnWidth: number;
|
||||
}): number {
|
||||
if (args.activityColumnWidth <= 0 && args.logColumnWidth <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const feedCardTopInset = ACTIVITY_LANE.headerHeight + FEED_HEADER_BOTTOM_GAP;
|
||||
const taskPillTopInset = KANBAN_ZONE.headerHeight - TASK_PILL.height / 2;
|
||||
return Math.max(0, feedCardTopInset - taskPillTopInset);
|
||||
}
|
||||
|
||||
function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] {
|
||||
const candidates: GraphOwnerSlotAssignment[] = [];
|
||||
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.21",
|
||||
"sourceRef": "v0.0.21",
|
||||
"version": "0.0.22",
|
||||
"sourceRef": "v0.0.22",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.22.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.22.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.22.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.22.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
|
||||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||
|
||||
interface StableRectLike {
|
||||
left: number;
|
||||
|
|
@ -75,7 +76,12 @@ function formatRelativeTime(timestamp: string): string {
|
|||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||
const className = 'size-3.5 shrink-0';
|
||||
const title = item.title.trim().toLowerCase();
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
}
|
||||
if (
|
||||
title.includes('message') ||
|
||||
title.includes('comment') ||
|
||||
title === 'send message' ||
|
||||
title === 'message sent' ||
|
||||
title === 'add comment' ||
|
||||
|
|
@ -83,9 +89,6 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
|||
) {
|
||||
return <MessageSquareText className={`${className} text-sky-300`} />;
|
||||
}
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
}
|
||||
if (item.kind === 'tool_result') {
|
||||
return <CheckCircle2 className={`${className} text-emerald-300`} />;
|
||||
}
|
||||
|
|
@ -106,6 +109,14 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading:
|
|||
return 'No recent logs';
|
||||
}
|
||||
|
||||
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
||||
const title = item.title.trim();
|
||||
if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) {
|
||||
return title.slice(0, -' result'.length).trim() || title;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function setShellHidden(shell: HTMLDivElement): void {
|
||||
shell.style.opacity = '0';
|
||||
shell.style.pointerEvents = 'none';
|
||||
|
|
@ -125,7 +136,12 @@ export const GraphMemberLogPreviewHud = ({
|
|||
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const visibleKeyRef = useRef('');
|
||||
const knownItemIdsByMemberRef = useRef(new Map<string, Set<string>>());
|
||||
const highlightTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
const [visibleMemberNames, setVisibleMemberNames] = useState<string[]>([]);
|
||||
const [highlightedItemIds, setHighlightedItemIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const { teamData } = useGraphActivityContext(teamName);
|
||||
const members = teamData?.members ?? [];
|
||||
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
|
||||
|
|
@ -155,6 +171,69 @@ export const GraphMemberLogPreviewHud = ({
|
|||
[onOpenMemberProfile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
knownItemIdsByMemberRef.current.clear();
|
||||
setHighlightedItemIds(new Set());
|
||||
for (const timer of highlightTimersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
highlightTimersRef.current.clear();
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of highlightTimersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
highlightTimersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const newItemIds: string[] = [];
|
||||
for (const [memberKey, preview] of previewsByMember) {
|
||||
const currentIds = new Set(preview.items.map((item) => item.id));
|
||||
const knownIds = knownItemIdsByMemberRef.current.get(memberKey);
|
||||
if (knownIds) {
|
||||
for (const itemId of currentIds) {
|
||||
if (!knownIds.has(itemId)) {
|
||||
newItemIds.push(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
knownItemIdsByMemberRef.current.set(memberKey, currentIds);
|
||||
}
|
||||
|
||||
if (newItemIds.length === 0) return;
|
||||
|
||||
setHighlightedItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const itemId of newItemIds) {
|
||||
next.add(itemId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
for (const itemId of newItemIds) {
|
||||
const existingTimer = highlightTimersRef.current.get(itemId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
highlightTimersRef.current.delete(itemId);
|
||||
setHighlightedItemIds((current) => {
|
||||
if (!current.has(itemId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}, NEW_LOG_HIGHLIGHT_MS);
|
||||
highlightTimersRef.current.set(itemId, timer);
|
||||
}
|
||||
}, [enabled, previewsByMember]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
for (const shell of shellRefs.current.values()) {
|
||||
|
|
@ -285,29 +364,57 @@ export const GraphMemberLogPreviewHud = ({
|
|||
}, [enabled, forwardWheelToGraph, ownerNodes]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(memberName: string, item: MemberLogPreviewItem) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.52)] px-2.5 py-2 text-left text-[10px] leading-3 text-slate-400 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 inline-flex size-4 shrink-0 translate-y-0.5 items-center justify-center rounded bg-white/5 align-middle"
|
||||
aria-hidden="true"
|
||||
(memberName: string, item: MemberLogPreviewItem) => {
|
||||
const relativeTime = formatRelativeTime(item.timestamp);
|
||||
const displayTitle = compactDisplayTitle(item);
|
||||
const previewText = item.preview || item.sourceLabel || 'Log event';
|
||||
const titleText = relativeTime
|
||||
? `${item.title} ${relativeTime} ${previewText}`
|
||||
: `${item.title} ${previewText}`;
|
||||
const isHighlighted = highlightedItemIds.has(item.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
'block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]',
|
||||
isHighlighted
|
||||
? 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
|
||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)]',
|
||||
].join(' ')}
|
||||
title={titleText}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className="align-baseline text-[11px] font-medium leading-4 text-slate-200">
|
||||
{item.title}
|
||||
</span>{' '}
|
||||
<span className="align-baseline text-[9px] leading-4 text-slate-500">
|
||||
{formatRelativeTime(item.timestamp)}
|
||||
</span>{' '}
|
||||
<span className="align-baseline">{item.preview || item.sourceLabel || 'Log event'}</span>
|
||||
</button>
|
||||
),
|
||||
[openLogs]
|
||||
<span
|
||||
className="float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5 align-top"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex h-5 items-center align-top"
|
||||
style={{ position: 'relative', top: '-3px' }}
|
||||
>
|
||||
<span className="text-[11px] font-medium leading-none text-slate-200">
|
||||
{displayTitle}
|
||||
</span>
|
||||
{relativeTime ? (
|
||||
<span className="ml-1 text-[9px] font-normal leading-none text-slate-500">
|
||||
{relativeTime}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85"
|
||||
style={{ position: 'relative', top: '-3px' }}
|
||||
>
|
||||
{previewText}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
[highlightedItemIds, openLogs]
|
||||
);
|
||||
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export interface CodexLoginStateDto {
|
|||
status: CodexAccountLoginStatus;
|
||||
error: string | null;
|
||||
startedAt: string | null;
|
||||
authUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexRuntimeContextDto {
|
||||
|
|
|
|||
|
|
@ -692,6 +692,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
status: 'idle',
|
||||
error: loginState.status === 'failed' ? loginState.error : null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
};
|
||||
private pendingStartToken: symbol | null = null;
|
||||
private activeSession: {
|
||||
|
|
@ -71,6 +72,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'starting',
|
||||
error: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
authUrl: null,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -135,6 +137,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'pending',
|
||||
error: null,
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: authUrl.toString(),
|
||||
});
|
||||
|
||||
await shell.openExternal(authUrl.toString());
|
||||
|
|
@ -158,6 +161,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -170,6 +174,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'cancelled',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
this.emitSettled();
|
||||
return;
|
||||
|
|
@ -180,6 +185,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'cancelled',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -207,6 +213,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'cancelled',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
this.emitSettled();
|
||||
}
|
||||
|
|
@ -221,6 +228,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -234,6 +242,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -255,12 +264,14 @@ export class CodexLoginSessionManager {
|
|||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
status: 'failed',
|
||||
error: notification.error ?? 'ChatGPT login failed.',
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +292,7 @@ export class CodexLoginSessionManager {
|
|||
status: 'failed',
|
||||
error: errorMessage,
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
});
|
||||
this.emitSettled();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,76 @@ describe('memberLogPreviewExtractor', () => {
|
|||
expect(result.items[1]?.preview).toBe('older answer');
|
||||
});
|
||||
|
||||
it('extracts readable inbound task and comment messages without agent-only blocks', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'assigned',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: `New task assigned to you: #01d7462a *Calculator - final build and test command*
|
||||
|
||||
<info_for_agent>
|
||||
Hidden tool protocol that must not be rendered.
|
||||
</info_for_agent>
|
||||
|
||||
Description:
|
||||
Run final validation.`,
|
||||
}),
|
||||
message({
|
||||
uuid: 'comment',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: `**Comment on task #1dcfefd2** _Calculator - logic smoke checklist_
|
||||
|
||||
> Logic smoke check passed.
|
||||
|
||||
<info_for_agent>
|
||||
Reply to this comment using MCP tool task_add_comment.
|
||||
</info_for_agent>`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'Comment received',
|
||||
preview: '#1dcfefd2: Logic smoke check passed.',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'Task assigned',
|
||||
preview: '#01d7462a Calculator - final build and test command',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('info_for_agent');
|
||||
expect(JSON.stringify(result.items)).not.toContain('task_add_comment');
|
||||
});
|
||||
|
||||
it('skips meta tool-result user messages for inbound text extraction', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'meta',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
isMeta: true,
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: 'Internal runtime metadata',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||
const hugeOutput = 'x'.repeat(10_000);
|
||||
const result = extractMemberLogPreviewItems({
|
||||
|
|
@ -95,7 +165,7 @@ describe('memberLogPreviewExtractor', () => {
|
|||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool error',
|
||||
title: 'Bash error',
|
||||
tone: 'error',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
});
|
||||
|
|
@ -166,15 +236,64 @@ describe('memberLogPreviewExtractor', () => {
|
|||
title: 'Message sent',
|
||||
preview: 'Message sent to team-lead - #abc done',
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
|
||||
});
|
||||
|
||||
it('keeps known tool names on structured error payloads', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'send-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-send',
|
||||
name: 'agent-teams_message_send',
|
||||
input: {
|
||||
to: 'team-lead',
|
||||
summary: '#abc done',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'send-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-send',
|
||||
content: {
|
||||
success: false,
|
||||
message: 'Delivery failed',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Send message error',
|
||||
preview: 'Delivery failed',
|
||||
tone: 'error',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Send message',
|
||||
preview: 'to team-lead: #abc done',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
|
||||
});
|
||||
|
||||
it('formats task comment result payloads without raw JSON noise', () => {
|
||||
it('formats orphan comment result payloads without guessing add vs read semantics', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
|
|
@ -211,12 +330,119 @@ describe('memberLogPreviewExtractor', () => {
|
|||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment added',
|
||||
title: 'Comment',
|
||||
preview: 'Comment by tom on #task-799: Done with UI review',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('"comment"');
|
||||
});
|
||||
|
||||
it('uses tool context to name comment add results precisely', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'comment-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-comment',
|
||||
name: 'mcp__agent-teams__task_add_comment',
|
||||
input: {
|
||||
taskId: 'task-799',
|
||||
text: 'Done with UI review',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'comment-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-comment',
|
||||
content: JSON.stringify({
|
||||
taskId: 'task-799',
|
||||
comment: {
|
||||
id: 'comment-1',
|
||||
author: 'tom',
|
||||
text: 'Done with UI review',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment added',
|
||||
preview: 'Comment by tom on #task-799: Done with UI review',
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('distinguishes read-comment results from add-comment results', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'comment-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-comment',
|
||||
name: 'mcp__agent-teams__task_get_comment',
|
||||
input: {
|
||||
taskId: 'task-799',
|
||||
commentId: '47697aeb',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'comment-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-comment',
|
||||
content: JSON.stringify({
|
||||
agent_teams_task_get_comment_response: {
|
||||
taskId: 'task-799',
|
||||
comment: {
|
||||
id: '47697aeb-3734-4d5c-ae3e-42fafcbdea0b',
|
||||
author: 'tom',
|
||||
text: 'Готово по UI',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment loaded',
|
||||
preview: 'Comment by tom on #task-799: Готово по UI',
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(JSON.stringify(result.items)).not.toContain('Comment added');
|
||||
});
|
||||
|
||||
it('formats plain board tool results through the paired tool_use context', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
|
|
@ -257,6 +483,47 @@ describe('memberLogPreviewExtractor', () => {
|
|||
preview: 'Completed #abc12345',
|
||||
toolName: 'mcp__agent-teams__task_complete',
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps board tool input visible when the paired successful result is empty', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'complete-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-complete',
|
||||
name: 'mcp__agent-teams__task_complete',
|
||||
input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'complete-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-complete',
|
||||
content: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Complete task result',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Complete task',
|
||||
|
|
@ -284,7 +551,7 @@ describe('memberLogPreviewExtractor', () => {
|
|||
task: {
|
||||
id: 'abc12345-0000-0000-0000-000000000000',
|
||||
displayId: 'abc12345',
|
||||
title: 'Fix preview alignment',
|
||||
subject: 'Fix preview alignment',
|
||||
status: 'in_progress',
|
||||
owner: 'tom',
|
||||
},
|
||||
|
|
@ -304,6 +571,182 @@ describe('memberLogPreviewExtractor', () => {
|
|||
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
|
||||
});
|
||||
|
||||
it('formats common board and cross-team tool previews compactly', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'cross-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-cross',
|
||||
name: 'agent-teams_cross_team_send',
|
||||
input: {
|
||||
toTeam: 'design-team',
|
||||
summary: 'Need UI review',
|
||||
text: 'Please review compact logs',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-link',
|
||||
name: 'agent-teams_task_link',
|
||||
input: {
|
||||
taskId: 'abc12345',
|
||||
targetId: 'def67890',
|
||||
relationship: 'blocked-by',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'cross-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-cross',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Cross-team message',
|
||||
preview: 'to design-team: Need UI review',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Link tasks',
|
||||
preview: '#abc12345 blocked-by #def67890',
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses concrete names for generic runtime tool results', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'bash-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-bash',
|
||||
name: 'bash',
|
||||
input: {
|
||||
command: 'pnpm test',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'bash-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-bash',
|
||||
content: 'Tests passed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Bash result',
|
||||
preview: 'Tests passed',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Bash',
|
||||
preview: 'pnpm test',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not label arbitrary message fields as sent messages', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'generic-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-generic',
|
||||
content: {
|
||||
message: 'generic tool status',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool result',
|
||||
preview: 'generic tool status',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats unknown JSON string results without leaking raw JSON syntax', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'generic-json',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-generic',
|
||||
content: JSON.stringify({
|
||||
payload: {
|
||||
nested: true,
|
||||
},
|
||||
status: 'stored',
|
||||
count: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool result',
|
||||
preview: 'stored',
|
||||
});
|
||||
expect(result.items[0]?.preview).not.toContain('{');
|
||||
});
|
||||
|
||||
it('keeps orphan tool results visible because graph preview is diagnostic', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface MemberLogPreviewParsedMessage {
|
|||
role?: string;
|
||||
timestamp: Date | string;
|
||||
content: string | MemberLogPreviewContentBlock[];
|
||||
isMeta?: boolean;
|
||||
toolCalls?: readonly {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -57,6 +58,8 @@ interface Candidate {
|
|||
timestampMs: number;
|
||||
order: number;
|
||||
textTruncated: boolean;
|
||||
toolUseKey?: string;
|
||||
supersededByResult?: boolean;
|
||||
}
|
||||
|
||||
const UNKNOWN_TIMESTAMP_MS = 0;
|
||||
|
|
@ -139,6 +142,19 @@ function compactWhitespace(value: string): string {
|
|||
return stripAngleTags(value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function removeHiddenInstructionBlocks(value: string): string {
|
||||
let result = value;
|
||||
for (const tag of [
|
||||
'info_for_agent',
|
||||
'opencode_runtime_identity',
|
||||
'opencode_app_message_delivery',
|
||||
'system-reminder',
|
||||
]) {
|
||||
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function looksLikeJsonPayload(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
|
|
@ -272,24 +288,89 @@ function canonicalToolNameFromWrapperKey(value: string | undefined): string | nu
|
|||
);
|
||||
}
|
||||
|
||||
function humanizeFallbackToolName(toolName: string): string {
|
||||
const stripped = canonicalToolName(toolName);
|
||||
if (!stripped) return 'Tool use';
|
||||
const compact = stripped.replace(/[_-]+/g, ' ').trim();
|
||||
if (!compact) return toolName.trim() || 'Tool use';
|
||||
const lower = compact.toLowerCase();
|
||||
if (lower === 'bash' || lower === 'shell') return 'Bash';
|
||||
if (lower === 'read') return 'Read';
|
||||
if (lower === 'write') return 'Write';
|
||||
if (lower === 'edit') return 'Edit';
|
||||
if (lower === 'grep') return 'Grep';
|
||||
if (lower === 'glob') return 'Glob';
|
||||
if (lower === 'ls') return 'List files';
|
||||
return compact
|
||||
.split(' ')
|
||||
.map((part) => (part.length > 0 ? `${part[0]?.toUpperCase()}${part.slice(1)}` : part))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function formatToolTitle(toolName: string): string {
|
||||
const canonical = canonicalToolName(toolName);
|
||||
if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message';
|
||||
if (canonical === 'cross_team_send') return 'Cross-team message';
|
||||
if (canonical === 'runtime_deliver_message') return 'Runtime delivery';
|
||||
if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task';
|
||||
if (canonical === 'task_complete') return 'Complete task';
|
||||
if (canonical === 'task_add_comment') return 'Add comment';
|
||||
if (canonical === 'task_get_comment') return 'Read comment';
|
||||
if (canonical === 'task_get') return 'Read task';
|
||||
if (canonical === 'task_list') return 'List tasks';
|
||||
if (canonical === 'task_briefing') return 'Task briefing';
|
||||
if (canonical === 'task_start') return 'Start task';
|
||||
if (canonical === 'task_set_status') return 'Set status';
|
||||
if (canonical === 'task_set_owner') return 'Set owner';
|
||||
if (canonical === 'task_set_clarification') return 'Set clarification';
|
||||
if (canonical === 'task_attach_file') return 'Attach file';
|
||||
if (canonical === 'task_attach_comment_file') return 'Attach comment file';
|
||||
if (canonical === 'task_link') return 'Link tasks';
|
||||
if (canonical === 'task_unlink') return 'Unlink tasks';
|
||||
if (canonical === 'task_restore') return 'Restore task';
|
||||
if (canonical === 'review_request') return 'Request review';
|
||||
if (canonical === 'review_start') return 'Start review';
|
||||
if (canonical === 'review_approve') return 'Approve review';
|
||||
if (canonical === 'review_request_changes') return 'Request changes';
|
||||
if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in';
|
||||
if (canonical === 'member_briefing') return 'Member briefing';
|
||||
if (canonical === 'task_add') return 'Add task';
|
||||
return toolName.trim() || 'Tool use';
|
||||
if (canonical === 'task_update') return 'Update task';
|
||||
if (canonical === 'task_delete') return 'Delete task';
|
||||
if (canonical === 'process_list') return 'List processes';
|
||||
return humanizeFallbackToolName(toolName);
|
||||
}
|
||||
|
||||
function formatGenericToolResultTitle(
|
||||
toolContext: ToolUseContext | undefined,
|
||||
isError: boolean
|
||||
): string {
|
||||
if (!toolContext) {
|
||||
return isError ? 'Tool error' : 'Tool result';
|
||||
}
|
||||
return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`;
|
||||
}
|
||||
|
||||
function buildToolUseKey(input: {
|
||||
provider: MemberLogStreamProvider;
|
||||
sourceId: string;
|
||||
toolUseId: string;
|
||||
}): string {
|
||||
return [input.provider, input.sourceId, input.toolUseId.trim()].join(':');
|
||||
}
|
||||
|
||||
function isToolUseSupersededBySuccessResult(toolName: string): boolean {
|
||||
const canonical = canonicalToolName(toolName);
|
||||
return (
|
||||
canonical === 'sendmessage' ||
|
||||
canonical === 'message_send' ||
|
||||
canonical === 'cross_team_send' ||
|
||||
canonical === 'runtime_deliver_message' ||
|
||||
canonical === 'runtime_bootstrap_checkin' ||
|
||||
canonical === 'member_briefing' ||
|
||||
canonical.startsWith('task_') ||
|
||||
canonical.startsWith('review_')
|
||||
);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
|
|
@ -326,7 +407,8 @@ function taskRefFromPayload(
|
|||
}
|
||||
|
||||
function shortTaskSummary(task: Record<string, unknown> | undefined): string | null {
|
||||
const title = stringField(task, 'title') ?? stringField(task, 'name');
|
||||
const title =
|
||||
stringField(task, 'title') ?? stringField(task, 'subject') ?? stringField(task, 'name');
|
||||
const status = stringField(task, 'status');
|
||||
const owner = stringField(task, 'owner');
|
||||
const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter(
|
||||
|
|
@ -373,6 +455,64 @@ function formatTaskCommentPayload(
|
|||
return `Comment: ${commentText}`;
|
||||
}
|
||||
|
||||
function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatTaskCollectionPayload(payload: Record<string, unknown>): KnownPayloadPreview | null {
|
||||
const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']);
|
||||
const summary =
|
||||
stringField(payload, 'summary') ??
|
||||
stringField(payload, 'message') ??
|
||||
stringField(payload, 'text');
|
||||
if (taskCount != null) {
|
||||
return {
|
||||
title: 'Task list',
|
||||
text: summary ? `${taskCount} tasks - ${summary}` : `${taskCount} tasks`,
|
||||
};
|
||||
}
|
||||
return summary ? { title: 'Task list', text: summary } : null;
|
||||
}
|
||||
|
||||
function formatRelationshipPayload(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackInput?: Record<string, unknown> | null
|
||||
): string | null {
|
||||
const sourceRef = taskRefFromPayload(payload, fallbackInput);
|
||||
const targetRef = formatTaskRef(
|
||||
stringField(payload, 'targetId') ??
|
||||
stringField(payload, 'targetTaskId') ??
|
||||
stringField(fallbackInput ?? undefined, 'targetId') ??
|
||||
stringField(fallbackInput ?? undefined, 'targetTaskId')
|
||||
);
|
||||
const relationship =
|
||||
stringField(payload, 'relationship') ?? stringField(fallbackInput ?? undefined, 'relationship');
|
||||
if (sourceRef && targetRef && relationship) return `${sourceRef} ${relationship} ${targetRef}`;
|
||||
if (sourceRef && targetRef) return `${sourceRef} -> ${targetRef}`;
|
||||
if (sourceRef) return sourceRef;
|
||||
return targetRef;
|
||||
}
|
||||
|
||||
function formatReviewChangesText(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackInput?: Record<string, unknown> | null
|
||||
): string | null {
|
||||
return (
|
||||
stringField(payload, 'comment') ??
|
||||
stringField(payload, 'note') ??
|
||||
stringField(payload, 'message') ??
|
||||
stringField(fallbackInput ?? undefined, 'comment') ??
|
||||
stringField(fallbackInput ?? undefined, 'note') ??
|
||||
stringField(fallbackInput ?? undefined, 'message')
|
||||
);
|
||||
}
|
||||
|
||||
function formatTaskToolPayload(
|
||||
payload: Record<string, unknown>,
|
||||
canonicalToolNameValue: string | null,
|
||||
|
|
@ -393,13 +533,42 @@ function formatTaskToolPayload(
|
|||
const filename =
|
||||
stringField(payload, 'filename') ??
|
||||
stringField(payload, 'fileName') ??
|
||||
stringField(payload, 'path') ??
|
||||
stringField(payload, 'filePath') ??
|
||||
stringField(fallbackInput ?? undefined, 'filename') ??
|
||||
stringField(fallbackInput ?? undefined, 'fileName');
|
||||
stringField(fallbackInput ?? undefined, 'fileName') ??
|
||||
stringField(fallbackInput ?? undefined, 'path') ??
|
||||
stringField(fallbackInput ?? undefined, 'filePath');
|
||||
|
||||
if (canonical === 'task_add_comment') {
|
||||
const text = formatTaskCommentPayload(payload, fallbackInput);
|
||||
return text ? { title: 'Comment added', text } : null;
|
||||
}
|
||||
if (canonical === 'task_get_comment') {
|
||||
const text = formatTaskCommentPayload(payload, fallbackInput);
|
||||
if (text) return { title: 'Comment loaded', text };
|
||||
const commentId =
|
||||
stringField(payload, 'commentId') ?? stringField(fallbackInput ?? undefined, 'commentId');
|
||||
if (taskRef && commentId) {
|
||||
return { title: 'Comment loaded', text: `${commentId} on ${taskRef}` };
|
||||
}
|
||||
return taskRef ? { title: 'Comment loaded', text: `Loaded comment on ${taskRef}` } : null;
|
||||
}
|
||||
if (canonical === 'task_create' || canonical === 'task_create_from_message') {
|
||||
if (taskRef && taskSummary) {
|
||||
return { title: 'Task created', text: `${taskRef}: ${taskSummary}` };
|
||||
}
|
||||
if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` };
|
||||
}
|
||||
if (canonical === 'task_list' || canonical === 'task_briefing') {
|
||||
const collectionText = formatTaskCollectionPayload(payload);
|
||||
if (collectionText) {
|
||||
return {
|
||||
title: canonical === 'task_briefing' ? 'Task briefing' : collectionText.title,
|
||||
text: collectionText.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (canonical === 'task_start') {
|
||||
return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null;
|
||||
}
|
||||
|
|
@ -428,6 +597,19 @@ function formatTaskToolPayload(
|
|||
if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` };
|
||||
return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null;
|
||||
}
|
||||
if (canonical === 'task_attach_file') {
|
||||
if (taskRef && filename) return { title: 'Task file', text: `${filename} on ${taskRef}` };
|
||||
return taskRef ? { title: 'Task file', text: `Attached file to ${taskRef}` } : null;
|
||||
}
|
||||
if (canonical === 'task_link' || canonical === 'task_unlink') {
|
||||
const relationshipText = formatRelationshipPayload(payload, fallbackInput);
|
||||
if (relationshipText) {
|
||||
return {
|
||||
title: canonical === 'task_link' ? 'Tasks linked' : 'Tasks unlinked',
|
||||
text: relationshipText,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (canonical === 'review_request') {
|
||||
const reviewer =
|
||||
stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer');
|
||||
|
|
@ -438,6 +620,21 @@ function formatTaskToolPayload(
|
|||
if (canonical === 'review_start') {
|
||||
return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null;
|
||||
}
|
||||
if (canonical === 'review_approve') {
|
||||
const note = formatReviewChangesText(payload, fallbackInput);
|
||||
if (taskRef && note) return { title: 'Review approved', text: `${taskRef}: ${note}` };
|
||||
return taskRef ? { title: 'Review approved', text: `Approved ${taskRef}` } : null;
|
||||
}
|
||||
if (canonical === 'review_request_changes') {
|
||||
const comment = formatReviewChangesText(payload, fallbackInput);
|
||||
if (taskRef && comment) return { title: 'Changes requested', text: `${taskRef}: ${comment}` };
|
||||
return taskRef
|
||||
? { title: 'Changes requested', text: `Requested changes for ${taskRef}` }
|
||||
: null;
|
||||
}
|
||||
if (canonical === 'task_restore') {
|
||||
return taskRef ? { title: 'Task restored', text: `Restored ${taskRef}` } : null;
|
||||
}
|
||||
if (taskRef && status) {
|
||||
return { title: 'Task update', text: `Task ${taskRef} ${status}` };
|
||||
}
|
||||
|
|
@ -510,6 +707,22 @@ function formatMessageSendPayload(payload: Record<string, unknown>): string | nu
|
|||
return null;
|
||||
}
|
||||
|
||||
function looksLikeMessageSendPayload(payload: Record<string, unknown>): boolean {
|
||||
const routing = asRecord(payload.routing);
|
||||
const messageRecord = asRecord(payload.message);
|
||||
if (payload.deliveredToInbox === true || routing) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
messageRecord &&
|
||||
(stringField(messageRecord, 'to') ||
|
||||
stringField(messageRecord, 'from') ||
|
||||
stringField(messageRecord, 'summary') ||
|
||||
stringField(messageRecord, 'text') ||
|
||||
stringField(messageRecord, 'content'))
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageSendResultFromInput(payload: Record<string, unknown>): string | null {
|
||||
const target = stringField(payload, 'to') ?? stringField(payload, 'target');
|
||||
const summary =
|
||||
|
|
@ -536,6 +749,27 @@ function formatMessageSendInputPayload(payload: Record<string, unknown>): string
|
|||
return null;
|
||||
}
|
||||
|
||||
function formatCrossTeamPayload(payload: Record<string, unknown>): string | null {
|
||||
const routing = asRecord(payload.routing) ?? undefined;
|
||||
const target =
|
||||
stringField(payload, 'toTeam') ??
|
||||
stringField(payload, 'targetTeam') ??
|
||||
stringField(routing, 'toTeam') ??
|
||||
stringField(routing, 'targetTeam') ??
|
||||
stringField(routing, 'target');
|
||||
const summary =
|
||||
stringField(payload, 'summary') ??
|
||||
stringField(payload, 'message') ??
|
||||
stringField(payload, 'text') ??
|
||||
stringField(payload, 'content') ??
|
||||
stringField(routing, 'summary') ??
|
||||
stringField(routing, 'content');
|
||||
if (target && summary) return `to ${target}: ${summary}`;
|
||||
if (target) return `to ${target}`;
|
||||
if (summary) return summary;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPlainToolResultStatus(
|
||||
value: string,
|
||||
toolContext: ToolUseContext | undefined
|
||||
|
|
@ -552,6 +786,10 @@ function formatPlainToolResultStatus(
|
|||
const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null;
|
||||
return text ? { title: 'Message sent', text } : null;
|
||||
}
|
||||
if (toolContext.canonicalName === 'cross_team_send') {
|
||||
const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null;
|
||||
return text ? { title: 'Cross-team message', text } : null;
|
||||
}
|
||||
return (
|
||||
formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ??
|
||||
formatRuntimePayload({}, toolContext.canonicalName, fallbackInput)
|
||||
|
|
@ -568,6 +806,13 @@ function formatTaskToolInputPayload(
|
|||
const owner = stringField(payload, 'owner');
|
||||
const clarification = stringField(payload, 'clarification');
|
||||
const reviewer = stringField(payload, 'reviewer');
|
||||
const commentId = stringField(payload, 'commentId');
|
||||
const filename =
|
||||
stringField(payload, 'filename') ??
|
||||
stringField(payload, 'fileName') ??
|
||||
stringField(payload, 'filePath');
|
||||
const relationship = formatRelationshipPayload(payload, payload);
|
||||
const reviewText = formatReviewChangesText(payload, payload);
|
||||
|
||||
if (canonicalToolNameValue === 'task_add_comment') {
|
||||
if (taskRef && text) return `on ${taskRef}: ${text}`;
|
||||
|
|
@ -575,6 +820,10 @@ function formatTaskToolInputPayload(
|
|||
if (text) return text;
|
||||
return null;
|
||||
}
|
||||
if (canonicalToolNameValue === 'task_get_comment') {
|
||||
if (taskRef && commentId) return `${commentId} on ${taskRef}`;
|
||||
if (taskRef) return `comment on ${taskRef}`;
|
||||
}
|
||||
if (canonicalToolNameValue === 'task_set_status') {
|
||||
if (taskRef && status) return `${taskRef} -> ${status}`;
|
||||
}
|
||||
|
|
@ -587,6 +836,21 @@ function formatTaskToolInputPayload(
|
|||
if (canonicalToolNameValue === 'review_request') {
|
||||
if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`;
|
||||
}
|
||||
if (
|
||||
canonicalToolNameValue === 'review_approve' ||
|
||||
canonicalToolNameValue === 'review_request_changes'
|
||||
) {
|
||||
if (taskRef && reviewText) return `${taskRef}: ${reviewText}`;
|
||||
}
|
||||
if (
|
||||
canonicalToolNameValue === 'task_attach_file' ||
|
||||
canonicalToolNameValue === 'task_attach_comment_file'
|
||||
) {
|
||||
if (taskRef && filename) return `${filename} on ${taskRef}`;
|
||||
}
|
||||
if (canonicalToolNameValue === 'task_link' || canonicalToolNameValue === 'task_unlink') {
|
||||
if (relationship) return relationship;
|
||||
}
|
||||
if (taskRef) return taskRef;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -616,13 +880,24 @@ function formatKnownPayloadPreview(
|
|||
if (runtimeText) {
|
||||
return runtimeText;
|
||||
}
|
||||
const messageText = formatMessageSendPayload(payload);
|
||||
if (canonical === 'cross_team_send') {
|
||||
const crossTeamText = formatCrossTeamPayload(payload);
|
||||
if (crossTeamText) {
|
||||
return { title: 'Cross-team message', text: crossTeamText };
|
||||
}
|
||||
}
|
||||
const messageText =
|
||||
canonical === 'sendmessage' ||
|
||||
canonical === 'message_send' ||
|
||||
looksLikeMessageSendPayload(payload)
|
||||
? formatMessageSendPayload(payload)
|
||||
: null;
|
||||
if (messageText) {
|
||||
return { title: 'Message sent', text: messageText };
|
||||
}
|
||||
const commentText = formatTaskCommentPayload(payload);
|
||||
if (commentText) {
|
||||
return { title: 'Comment added', text: commentText };
|
||||
return { title: 'Comment', text: commentText };
|
||||
}
|
||||
const taskText = formatTaskStatusPayload(payload, fallbackInput);
|
||||
if (taskText) {
|
||||
|
|
@ -646,6 +921,10 @@ function previewUnknownValue(
|
|||
if (plainStatus) {
|
||||
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
|
||||
}
|
||||
const parsed = parseJsonLikeString(value);
|
||||
if (parsed != null) {
|
||||
return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
|
||||
}
|
||||
return truncatePreview(value, limit);
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
|
|
@ -694,6 +973,13 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
|
|||
return truncatePreview(formatted, limit);
|
||||
}
|
||||
}
|
||||
if (canonical === 'cross_team_send') {
|
||||
const payload = recordFromUnknown(value);
|
||||
const formatted = payload ? formatCrossTeamPayload(payload) : null;
|
||||
if (formatted) {
|
||||
return truncatePreview(formatted, limit);
|
||||
}
|
||||
}
|
||||
const payload = recordFromUnknown(value);
|
||||
if (payload) {
|
||||
const taskFormatted = formatTaskToolInputPayload(canonical, payload);
|
||||
|
|
@ -722,6 +1008,118 @@ function extractTextPreview(
|
|||
return preview.preview.length > 0 ? preview : null;
|
||||
}
|
||||
|
||||
function firstQuotedLine(value: string): string | null {
|
||||
const line = value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.startsWith('>'));
|
||||
return line ? line.replace(/^>\s*/, '').trim() || null : null;
|
||||
}
|
||||
|
||||
function findLineByPrefix(value: string, prefix: string): string | null {
|
||||
const normalizedPrefix = prefix.toLowerCase();
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.toLowerCase().startsWith(normalizedPrefix)) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTaskAssignmentLine(line: string): { taskRef: string; subject?: string } | null {
|
||||
const prefix = 'New task assigned to you:';
|
||||
if (!line.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
const rest = line.slice(prefix.length).trim();
|
||||
const [taskRefCandidate = '', ...restParts] = rest.split(/\s+/);
|
||||
if (!taskRefCandidate.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
const restText = restParts.join(' ').trim();
|
||||
const firstStar = restText.indexOf('*');
|
||||
const secondStar = firstStar >= 0 ? restText.indexOf('*', firstStar + 1) : -1;
|
||||
const subject =
|
||||
firstStar >= 0 && secondStar > firstStar
|
||||
? restText.slice(firstStar + 1, secondStar).trim()
|
||||
: restText.replaceAll('*', '').trim();
|
||||
return {
|
||||
taskRef: taskRefCandidate,
|
||||
...(subject ? { subject } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCommentHeadingLine(line: string): { taskRef: string; subject?: string } | null {
|
||||
const prefix = '**Comment on task ';
|
||||
if (!line.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
const afterPrefix = line.slice(prefix.length);
|
||||
const endRef = afterPrefix.indexOf('**');
|
||||
if (endRef <= 0) {
|
||||
return null;
|
||||
}
|
||||
const taskRef = afterPrefix.slice(0, endRef).trim();
|
||||
if (!taskRef.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
const afterRef = afterPrefix.slice(endRef + 2).trim();
|
||||
const firstUnderscore = afterRef.indexOf('_');
|
||||
const secondUnderscore = firstUnderscore >= 0 ? afterRef.indexOf('_', firstUnderscore + 1) : -1;
|
||||
const subject =
|
||||
firstUnderscore >= 0 && secondUnderscore > firstUnderscore
|
||||
? afterRef.slice(firstUnderscore + 1, secondUnderscore).trim()
|
||||
: undefined;
|
||||
return {
|
||||
taskRef,
|
||||
...(subject ? { subject } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractInboundTextPreview(
|
||||
content: string | MemberLogPreviewContentBlock[],
|
||||
textLimit: number
|
||||
): { title: string; preview: string; truncated: boolean } | null {
|
||||
const raw =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content
|
||||
.filter((block): block is Extract<MemberLogPreviewContentBlock, { type: 'text' }> => {
|
||||
return block.type === 'text' && typeof block.text === 'string';
|
||||
})
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
const visibleRaw = removeHiddenInstructionBlocks(raw);
|
||||
const compact = compactWhitespace(visibleRaw);
|
||||
if (!compact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assigned = parseTaskAssignmentLine(
|
||||
findLineByPrefix(visibleRaw, 'New task assigned to you:') ?? ''
|
||||
);
|
||||
if (assigned) {
|
||||
const taskRef = assigned.taskRef;
|
||||
const subject = assigned.subject;
|
||||
const preview = truncatePreview(subject ? `${taskRef} ${subject}` : taskRef, textLimit);
|
||||
return { title: 'Task assigned', ...preview };
|
||||
}
|
||||
|
||||
const comment = parseCommentHeadingLine(findLineByPrefix(visibleRaw, '**Comment on task ') ?? '');
|
||||
if (comment) {
|
||||
const taskRef = comment.taskRef;
|
||||
const quoted = firstQuotedLine(visibleRaw);
|
||||
const subject = comment.subject;
|
||||
const text = quoted ?? subject ?? 'Comment received';
|
||||
const preview = truncatePreview(`${taskRef}: ${text}`, textLimit);
|
||||
return { title: 'Comment received', ...preview };
|
||||
}
|
||||
|
||||
const preview = truncatePreview(compact, textLimit);
|
||||
return preview.preview ? { title: 'Message', ...preview } : null;
|
||||
}
|
||||
|
||||
function isToolUseBlock(
|
||||
block: MemberLogPreviewContentBlock
|
||||
): block is Extract<MemberLogPreviewContentBlock, { type: 'tool_use' }> {
|
||||
|
|
@ -792,6 +1190,13 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string {
|
|||
return message.role ?? message.type ?? '';
|
||||
}
|
||||
|
||||
function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean {
|
||||
if ((message.toolResults?.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(message.content) && message.content.some(isToolResultBlock);
|
||||
}
|
||||
|
||||
function buildItemId(input: {
|
||||
provider: MemberLogStreamProvider;
|
||||
sourceId: string;
|
||||
|
|
@ -824,6 +1229,8 @@ function buildCandidate(input: {
|
|||
laneId?: string;
|
||||
token: string;
|
||||
textTruncated: boolean;
|
||||
toolUseKey?: string;
|
||||
supersededByResult?: boolean;
|
||||
}): Candidate {
|
||||
const timestamp = timestampIso(input.message.timestamp);
|
||||
const messageId = input.message.uuid ?? `message-${input.messageIndex}`;
|
||||
|
|
@ -850,6 +1257,8 @@ function buildCandidate(input: {
|
|||
timestampMs: timestampMs(input.message.timestamp),
|
||||
order: input.messageIndex * 1_000 + input.blockIndex,
|
||||
textTruncated: input.textTruncated,
|
||||
...(input.toolUseKey ? { toolUseKey: input.toolUseKey } : {}),
|
||||
...(input.supersededByResult ? { supersededByResult: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -873,6 +1282,11 @@ function collectToolUseCandidates(input: {
|
|||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const preview = previewToolInputValue(tool.name, tool.input, input.textLimit);
|
||||
const toolUseKey = buildToolUseKey({
|
||||
provider: input.provider,
|
||||
sourceId: input.sourceId,
|
||||
toolUseId: id,
|
||||
});
|
||||
candidates.push(
|
||||
buildCandidate({
|
||||
provider: input.provider,
|
||||
|
|
@ -890,6 +1304,8 @@ function collectToolUseCandidates(input: {
|
|||
laneId: input.laneId,
|
||||
token: id,
|
||||
textTruncated: preview.truncated,
|
||||
toolUseKey,
|
||||
supersededByResult: isToolUseSupersededBySuccessResult(tool.name),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -933,6 +1349,11 @@ function collectToolResultCandidates(input: {
|
|||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const toolContext = input.toolUseContexts.get(id);
|
||||
const toolUseKey = buildToolUseKey({
|
||||
provider: input.provider,
|
||||
sourceId: input.sourceId,
|
||||
toolUseId: id,
|
||||
});
|
||||
const preview = previewUnknownValue(
|
||||
result.content,
|
||||
input.textLimit,
|
||||
|
|
@ -940,6 +1361,10 @@ function collectToolResultCandidates(input: {
|
|||
toolContext
|
||||
);
|
||||
const isError = result.isError === true || preview.title === 'Tool error';
|
||||
const title =
|
||||
preview.title === 'Tool error'
|
||||
? formatGenericToolResultTitle(toolContext, true)
|
||||
: (preview.title ?? formatGenericToolResultTitle(toolContext, isError));
|
||||
candidates.push(
|
||||
buildCandidate({
|
||||
provider: input.provider,
|
||||
|
|
@ -948,7 +1373,7 @@ function collectToolResultCandidates(input: {
|
|||
messageIndex: input.messageIndex,
|
||||
blockIndex,
|
||||
kind: 'tool_result',
|
||||
title: isError ? 'Tool error' : (preview.title ?? 'Tool result'),
|
||||
title,
|
||||
preview: preview.preview,
|
||||
tone: isError ? 'error' : 'success',
|
||||
toolName: toolContext?.name,
|
||||
|
|
@ -957,6 +1382,7 @@ function collectToolResultCandidates(input: {
|
|||
laneId: input.laneId,
|
||||
token: id,
|
||||
textTruncated: preview.truncated,
|
||||
toolUseKey,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -1078,9 +1504,50 @@ export function extractMemberLogPreviewItems(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) {
|
||||
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
|
||||
if (inboundPreview) {
|
||||
candidates.push(
|
||||
buildCandidate({
|
||||
provider: input.provider,
|
||||
sourceId,
|
||||
message,
|
||||
messageIndex,
|
||||
blockIndex: 8,
|
||||
kind: 'text',
|
||||
title: inboundPreview.title,
|
||||
preview: inboundPreview.preview,
|
||||
tone: 'neutral',
|
||||
sourceLabel: input.sourceLabel,
|
||||
sessionId: input.sessionId ?? message.sessionId,
|
||||
laneId: input.laneId,
|
||||
token: 'inbound-text',
|
||||
textTruncated: inboundPreview.truncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = [...candidates];
|
||||
const successfulResultToolKeys = new Set(
|
||||
candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.item.kind === 'tool_result' &&
|
||||
candidate.item.tone !== 'error' &&
|
||||
Boolean(candidate.item.preview?.trim())
|
||||
)
|
||||
.map((candidate) => candidate.toolUseKey)
|
||||
.filter((toolUseKey): toolUseKey is string => Boolean(toolUseKey))
|
||||
);
|
||||
const compactCandidates = candidates.filter((candidate) => {
|
||||
if (candidate.item.kind !== 'tool_use') return true;
|
||||
if (!candidate.supersededByResult || !candidate.toolUseKey) return true;
|
||||
return !successfulResultToolKeys.has(candidate.toolUseKey);
|
||||
});
|
||||
|
||||
const sorted = [...compactCandidates];
|
||||
sorted.sort((left, right) => {
|
||||
const byTime = right.timestampMs - left.timestampMs;
|
||||
if (byTime !== 0) return byTime;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
isConnectionManagedRuntimeProvider,
|
||||
shouldShowProviderConnectAction,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
|
|
@ -102,7 +103,7 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
|
|||
}
|
||||
|
||||
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
|
||||
return null;
|
||||
return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null;
|
||||
}
|
||||
|
||||
const usageHint = codex.localActiveChatgptAccountPresent
|
||||
|
|
@ -731,6 +732,8 @@ const InstalledBanner = ({
|
|||
provider.connection?.codex?.launchAllowed !== true &&
|
||||
provider.connection?.codex?.login.status !== 'starting' &&
|
||||
provider.connection?.codex?.login.status !== 'pending';
|
||||
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
|
||||
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
|
|
@ -897,20 +900,33 @@ const InstalledBanner = ({
|
|||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">{codexDashboardHint}</span>
|
||||
{codexNeedsReconnect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCodexReconnect}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Reconnect ChatGPT
|
||||
</button>
|
||||
{showCodexLoginActions ? (
|
||||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
size="xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (codexLoginAuthUrl) {
|
||||
void api.openExternal(codexLoginAuthUrl);
|
||||
return;
|
||||
}
|
||||
onCodexReconnect();
|
||||
}}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
{codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
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,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -715,6 +716,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession;
|
||||
const codexLoginPending =
|
||||
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
||||
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
|
||||
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
||||
const configuredAuthMode: CliProviderAuthMode | undefined =
|
||||
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
||||
|
|
@ -1389,14 +1391,31 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
Refresh
|
||||
</Button>
|
||||
{codexLoginPending ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexCancelLogin()}
|
||||
>
|
||||
Cancel login
|
||||
</Button>
|
||||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
disabled={codexActionBusy}
|
||||
/>
|
||||
{codexLoginAuthUrl ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void api.openExternal(codexLoginAuthUrl)}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
Open login
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexCancelLogin()}
|
||||
>
|
||||
Cancel login
|
||||
</Button>
|
||||
</>
|
||||
) : codexHasActiveChatgptSession ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -1407,15 +1426,21 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
Disconnect account
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexStartLogin()}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
||||
</Button>
|
||||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
disabled={codexActionBusy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={codexActionBusy}
|
||||
onClick={() => void handleCodexStartLogin()}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { markTaskUnread } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
|
|
@ -283,6 +284,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
setRenamingTaskKey(null);
|
||||
}, []);
|
||||
|
||||
const handleMarkTaskUnread = useCallback((teamName: string, taskId: string): void => {
|
||||
markTaskUnread(teamName, taskId);
|
||||
}, []);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
async (teamName: string, taskId: string): Promise<void> => {
|
||||
const confirmed = await confirm({
|
||||
|
|
@ -548,6 +553,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
isArchived={false}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
|
|
@ -641,6 +647,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
|
|
@ -726,6 +733,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
onToggleArchive={() =>
|
||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||
}
|
||||
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
|
|
@ -832,6 +840,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
onToggleArchive={() =>
|
||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||
}
|
||||
onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { clearTaskManualUnread } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
|
|
@ -157,6 +158,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
style={{ borderColor: 'var(--color-border)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
clearTaskManualUnread(task.teamName, task.id);
|
||||
openGlobalTaskDetail(task.teamName, task.id);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@renderer/components/ui/context-menu';
|
||||
import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
|
||||
import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { GlobalTask } from '@shared/types';
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ export interface TaskContextMenuProps {
|
|||
isArchived: boolean;
|
||||
onTogglePin: () => void;
|
||||
onToggleArchive: () => void;
|
||||
onMarkUnread: () => void;
|
||||
onRename: () => void;
|
||||
onDelete?: () => void;
|
||||
children: React.ReactNode;
|
||||
|
|
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
|
|||
isArchived,
|
||||
onTogglePin,
|
||||
onToggleArchive,
|
||||
onMarkUnread,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
|
|
@ -55,6 +57,11 @@ export const TaskContextMenu = ({
|
|||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={onMarkUnread}>
|
||||
<Mail className="size-3.5 shrink-0" />
|
||||
<span>Mark as unread</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onSelect={onToggleArchive}>
|
||||
|
|
@ -74,10 +81,7 @@ export const TaskContextMenu = ({
|
|||
{onDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={onDelete}
|
||||
className="text-red-400 focus:text-red-400"
|
||||
>
|
||||
<ContextMenuItem onSelect={onDelete} className="text-red-400 focus:text-red-400">
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>Delete task</span>
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
|
|
@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
return (
|
||||
<MemberList
|
||||
{...props}
|
||||
teamName={teamName}
|
||||
leadActivity={leadActivity}
|
||||
memberSpawnStatuses={memberSpawnStatusMap}
|
||||
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 { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
|
|
@ -95,6 +96,7 @@ import {
|
|||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
|
|
@ -155,7 +157,6 @@ const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskU
|
|||
|
||||
import type {
|
||||
EffortLevel,
|
||||
Project,
|
||||
TeamCreateRequest,
|
||||
TeamFastMode,
|
||||
TeamProviderId,
|
||||
|
|
@ -402,7 +403,7 @@ export const CreateTeamDialog = ({
|
|||
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
||||
|
||||
// ── Transient UI state (NOT persisted) ───────────────────────────────
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
|
@ -709,6 +710,19 @@ export const CreateTeamDialog = ({
|
|||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
const handleCodexReconnect = useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
prepareRequestSeqRef.current += 1;
|
||||
|
|
@ -948,36 +962,11 @@ export const CreateTeamDialog = ({
|
|||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const nextProjects = (await api.getProjects()).filter(
|
||||
(project) => !isEphemeralProjectPath(project.path)
|
||||
);
|
||||
const nextProjects = await loadProjectPathProjects({ defaultProjectPath });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If defaultProjectPath is set but not in the fetched list (e.g. new project
|
||||
// without Claude sessions), add it as a synthetic entry so the Combobox can
|
||||
// display and select it.
|
||||
const normalizedDefaultProjectPath = defaultProjectPath
|
||||
? normalizePath(defaultProjectPath)
|
||||
: null;
|
||||
if (
|
||||
defaultProjectPath &&
|
||||
normalizedDefaultProjectPath &&
|
||||
!isEphemeralProjectPath(defaultProjectPath) &&
|
||||
!nextProjects.some((p) => normalizePath(p.path) === normalizedDefaultProjectPath)
|
||||
) {
|
||||
const folderName =
|
||||
defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath;
|
||||
nextProjects.unshift({
|
||||
id: defaultProjectPath.replace(/[/\\]/g, '-'),
|
||||
path: defaultProjectPath,
|
||||
name: folderName,
|
||||
sessions: [],
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
setProjects(nextProjects);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
|
|
@ -1552,6 +1541,12 @@ export const CreateTeamDialog = ({
|
|||
}),
|
||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||
);
|
||||
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
|
||||
effectiveCliStatus,
|
||||
selectedProviderIds: selectedMemberProviders,
|
||||
prepareMessage: effectivePrepare.message,
|
||||
prepareChecks,
|
||||
});
|
||||
const canOpenExistingTeam =
|
||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
|
|
@ -2117,8 +2112,8 @@ export const CreateTeamDialog = ({
|
|||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{prepareWarnings.map((warning, index) => (
|
||||
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -2152,9 +2147,9 @@ export const CreateTeamDialog = ({
|
|||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-1 space-y-0.5 pl-6">
|
||||
{prepareWarnings.map((warning) => (
|
||||
{prepareWarnings.map((warning, index) => (
|
||||
<p
|
||||
key={warning}
|
||||
key={`${index}:${warning}`}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
|
|
@ -2166,6 +2161,15 @@ export const CreateTeamDialog = ({
|
|||
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
|
||||
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
|
||||
</p>
|
||||
{showCodexReconnectPrompt ? (
|
||||
<div className="pl-6">
|
||||
<CodexReconnectPrompt
|
||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
|||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
|
|
@ -100,6 +101,7 @@ import {
|
|||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
|
|
@ -153,7 +155,6 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
|||
import type {
|
||||
CreateScheduleInput,
|
||||
EffortLevel,
|
||||
Project,
|
||||
ResolvedTeamMember,
|
||||
Schedule,
|
||||
ScheduleLaunchConfig,
|
||||
|
|
@ -404,7 +405,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const chipDraft = useChipDraftPersistence(
|
||||
`launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips`
|
||||
);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
|
@ -586,6 +587,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
});
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
|
||||
|
||||
const handleCodexReconnect = React.useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
const updateSchedule = useStore((s) => s.updateSchedule);
|
||||
|
|
@ -1579,6 +1593,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
|
||||
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -1589,30 +1604,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const apiProjects = (await api.getProjects()).filter(
|
||||
(project) => !isEphemeralProjectPath(project.path)
|
||||
);
|
||||
const nextProjects = await loadProjectPathProjects({
|
||||
defaultProjectPath,
|
||||
repositoryGroups,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
const pathSet = new Set(apiProjects.map((p) => p.path));
|
||||
const extras: Project[] = [];
|
||||
for (const repo of repositoryGroups) {
|
||||
for (const wt of repo.worktrees) {
|
||||
if (!isEphemeralProjectPath(wt.path) && !pathSet.has(wt.path)) {
|
||||
pathSet.add(wt.path);
|
||||
extras.push({
|
||||
id: wt.id,
|
||||
path: wt.path,
|
||||
name: wt.name,
|
||||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: wt.createdAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProjects([...apiProjects, ...extras]);
|
||||
setProjects(nextProjects);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
|
||||
|
|
@ -1625,10 +1623,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, repositoryGroups]);
|
||||
}, [open, repositoryGroups, defaultProjectPath]);
|
||||
|
||||
// Pre-select defaultProjectPath (launch mode) or first project
|
||||
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
|
||||
|
|
@ -1920,6 +1917,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}),
|
||||
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
|
||||
);
|
||||
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
|
||||
effectiveCliStatus,
|
||||
selectedProviderIds: selectedMemberProviders,
|
||||
prepareMessage: effectivePrepare.message,
|
||||
prepareChecks,
|
||||
});
|
||||
const launchInFlight = useStore((s) =>
|
||||
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
||||
);
|
||||
|
|
@ -2819,8 +2822,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{prepareWarnings.map((warning, index) => (
|
||||
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -2858,9 +2861,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-1 space-y-0.5 pl-6">
|
||||
{prepareWarnings.map((warning) => (
|
||||
{prepareWarnings.map((warning, index) => (
|
||||
<p
|
||||
key={warning}
|
||||
key={`${index}:${warning}`}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
|
|
@ -2889,6 +2892,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showCodexReconnectPrompt ? (
|
||||
<div className="pl-6">
|
||||
<CodexReconnectPrompt
|
||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
|
|
@ -8,9 +9,14 @@ import { Label } from '@renderer/components/ui/label';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, FolderOpen } from 'lucide-react';
|
||||
|
||||
import { buildProjectPathOptions } from './projectPathOptions';
|
||||
import {
|
||||
buildProjectPathOptions,
|
||||
type ProjectPathOptionMeta,
|
||||
type ProjectPathProject,
|
||||
} from './projectPathOptions';
|
||||
|
||||
import type { Project } from '@shared/types';
|
||||
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
|
||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
|
@ -45,6 +51,49 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | undefined {
|
||||
return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource;
|
||||
}
|
||||
|
||||
function getSourceLabel(source: DashboardRecentProjectSource): string {
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
return 'Found by Claude';
|
||||
case 'codex':
|
||||
return 'Found by Codex';
|
||||
case 'mixed':
|
||||
return 'Found by Claude and Codex';
|
||||
}
|
||||
}
|
||||
|
||||
function ProjectSourceBadge({
|
||||
source,
|
||||
}: {
|
||||
source?: DashboardRecentProjectSource;
|
||||
}): React.JSX.Element | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logos =
|
||||
source === 'mixed'
|
||||
? (['anthropic', 'codex'] as const)
|
||||
: source === 'codex'
|
||||
? (['codex'] as const)
|
||||
: (['anthropic'] as const);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-1 py-0.5"
|
||||
title={getSourceLabel(source)}
|
||||
>
|
||||
{logos.map((providerId) => (
|
||||
<ProviderBrandLogo key={providerId} providerId={providerId} className="size-3" />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type CwdMode = 'project' | 'custom';
|
||||
|
||||
interface ProjectPathSelectorProps {
|
||||
|
|
@ -54,7 +103,7 @@ interface ProjectPathSelectorProps {
|
|||
onSelectedProjectPathChange: (path: string) => void;
|
||||
customCwd: string;
|
||||
onCustomCwdChange: (cwd: string) => void;
|
||||
projects: Project[];
|
||||
projects: ProjectPathProject[];
|
||||
projectsLoading: boolean;
|
||||
projectsError: string | null;
|
||||
fieldError?: string | null;
|
||||
|
|
@ -123,6 +172,12 @@ export const ProjectPathSelector = ({
|
|||
searchPlaceholder="Search project by name or path"
|
||||
emptyMessage="Nothing found"
|
||||
disabled={projectsLoading || projectOptions.length === 0}
|
||||
renderTriggerLabel={(option) => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</span>
|
||||
)}
|
||||
renderOption={(option, isSelected, query) => (
|
||||
<>
|
||||
<Check
|
||||
|
|
@ -131,6 +186,7 @@ export const ProjectPathSelector = ({
|
|||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-[var(--color-text)]">
|
||||
{renderHighlightedText(option.label, query)}
|
||||
|
|
|
|||
|
|
@ -619,9 +619,9 @@ export const ProvisioningProviderStatusList = ({
|
|||
</div>
|
||||
{visibleDetails.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||
{visibleDetails.map((detail) => (
|
||||
{visibleDetails.map((detail, index) => (
|
||||
<p
|
||||
key={detail}
|
||||
key={`${check.providerId}:${index}:${detail}`}
|
||||
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
|
||||
>
|
||||
{detail}
|
||||
|
|
|
|||
|
|
@ -563,7 +563,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
}}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-0.5">
|
||||
<span className="leading-tight">{opt.label}</span>
|
||||
<span
|
||||
className={cn('leading-tight', opt.value === 'gpt-5.5' && 'font-bold')}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{sourceBadgeLabel ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||
|
|
|
|||
|
|
@ -2,13 +2,25 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
|
||||
import type { Project } from '@shared/types';
|
||||
|
||||
function toProjectOption(project: Project): ComboboxOption {
|
||||
export interface ProjectPathProject extends Project {
|
||||
discoverySource?: DashboardRecentProjectSource;
|
||||
}
|
||||
|
||||
export interface ProjectPathOptionMeta {
|
||||
discoverySource?: DashboardRecentProjectSource;
|
||||
}
|
||||
|
||||
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
||||
return {
|
||||
value: project.path,
|
||||
label: project.name,
|
||||
description: project.path,
|
||||
meta: {
|
||||
discoverySource: project.discoverySource,
|
||||
} satisfies ProjectPathOptionMeta,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -17,7 +29,7 @@ function toProjectOption(project: Project): ComboboxOption {
|
|||
* This keeps combobox item values unique even when scanner sources overlap.
|
||||
*/
|
||||
export function buildProjectPathOptions(
|
||||
projects: Project[],
|
||||
projects: ProjectPathProject[],
|
||||
preferredPath?: string
|
||||
): ComboboxOption[] {
|
||||
const options: ComboboxOption[] = [];
|
||||
|
|
|
|||
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 {
|
||||
formatMemberActivityElapsed,
|
||||
readMemberActivityTimerElapsed,
|
||||
syncMemberActivityTimer,
|
||||
} from '@renderer/utils/memberActivityTimer';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface CurrentTaskIndicatorProps {
|
||||
|
|
@ -10,9 +16,71 @@ interface CurrentTaskIndicatorProps {
|
|||
borderColor: string;
|
||||
maxSubjectLength?: number;
|
||||
activityLabel?: string;
|
||||
activityTimer?: MemberActivityTimerAnchor | null;
|
||||
isTimerRunning?: boolean;
|
||||
onOpenTask?: () => void;
|
||||
}
|
||||
|
||||
function useActivityTimerLabel(
|
||||
activityTimer: MemberActivityTimerAnchor | null | undefined,
|
||||
isTimerRunning: boolean
|
||||
): string | null {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!activityTimer) return;
|
||||
const now = Date.now();
|
||||
syncMemberActivityTimer({
|
||||
timerId: activityTimer.timerId,
|
||||
startedAtMs: activityTimer.startedAtMs,
|
||||
baseElapsedMs: activityTimer.baseElapsedMs,
|
||||
running: isTimerRunning,
|
||||
runId: activityTimer.runId,
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
return () => {
|
||||
syncMemberActivityTimer({
|
||||
timerId: activityTimer.timerId,
|
||||
startedAtMs: activityTimer.startedAtMs,
|
||||
baseElapsedMs: activityTimer.baseElapsedMs,
|
||||
running: isTimerRunning,
|
||||
runId: activityTimer.runId,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
};
|
||||
}, [activityTimer, isTimerRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activityTimer || !isTimerRunning) return;
|
||||
const handle = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
syncMemberActivityTimer({
|
||||
timerId: activityTimer.timerId,
|
||||
startedAtMs: activityTimer.startedAtMs,
|
||||
baseElapsedMs: activityTimer.baseElapsedMs,
|
||||
running: true,
|
||||
runId: activityTimer.runId,
|
||||
nowMs: now,
|
||||
});
|
||||
setNowMs(now);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [activityTimer, isTimerRunning]);
|
||||
|
||||
if (!activityTimer) return null;
|
||||
return formatMemberActivityElapsed(
|
||||
readMemberActivityTimerElapsed({
|
||||
timerId: activityTimer.timerId,
|
||||
startedAtMs: activityTimer.startedAtMs,
|
||||
baseElapsedMs: activityTimer.baseElapsedMs,
|
||||
running: isTimerRunning,
|
||||
runId: activityTimer.runId,
|
||||
nowMs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline indicator showing a spinning loader + "working on" + task label button.
|
||||
* Shared between MemberCard and MemberHoverCard.
|
||||
|
|
@ -23,8 +91,11 @@ export const CurrentTaskIndicator = memo(
|
|||
borderColor,
|
||||
maxSubjectLength,
|
||||
activityLabel = 'working on',
|
||||
activityTimer,
|
||||
isTimerRunning = true,
|
||||
onOpenTask,
|
||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
|
||||
const subjectText =
|
||||
typeof maxSubjectLength === 'number' &&
|
||||
maxSubjectLength > 0 &&
|
||||
|
|
@ -54,6 +125,14 @@ export const CurrentTaskIndicator = memo(
|
|||
>
|
||||
{formatTaskDisplayLabel(task)} {subjectText}
|
||||
</button>
|
||||
{timerLabel ? (
|
||||
<span
|
||||
className="shrink-0 text-[9px] font-medium tabular-nums text-[var(--color-text-muted)]"
|
||||
title={`Active for ${timerLabel}`}
|
||||
>
|
||||
{timerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
|||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||
|
||||
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
|
|
@ -54,6 +55,10 @@ interface MemberCardProps {
|
|||
leadActivity?: LeadActivityState;
|
||||
currentTask?: TeamTaskWithKanban | null;
|
||||
reviewTask?: TeamTaskWithKanban | null;
|
||||
currentTaskTimer?: MemberActivityTimerAnchor | null;
|
||||
reviewTaskTimer?: MemberActivityTimerAnchor | null;
|
||||
currentTaskTimerRunning?: boolean;
|
||||
reviewTaskTimerRunning?: boolean;
|
||||
isAwaitingReply?: boolean;
|
||||
isRemoved?: boolean;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
|
|
@ -132,6 +137,10 @@ export const MemberCard = memo(function MemberCard({
|
|||
leadActivity,
|
||||
currentTask,
|
||||
reviewTask,
|
||||
currentTaskTimer,
|
||||
reviewTaskTimer,
|
||||
currentTaskTimerRunning = isTeamAlive !== false,
|
||||
reviewTaskTimerRunning = isTeamAlive !== false,
|
||||
isAwaitingReply,
|
||||
isRemoved,
|
||||
spawnStatus,
|
||||
|
|
@ -433,6 +442,8 @@ export const MemberCard = memo(function MemberCard({
|
|||
task={currentTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="working on"
|
||||
activityTimer={currentTaskTimer}
|
||||
isTimerRunning={currentTaskTimerRunning}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -441,6 +452,8 @@ export const MemberCard = memo(function MemberCard({
|
|||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="reviewing"
|
||||
activityTimer={reviewTaskTimer}
|
||||
isTimerRunning={reviewTaskTimerRunning}
|
||||
onOpenTask={onOpenReviewTask}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,15 @@ import {
|
|||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
shouldDisplayMemberCurrentTask,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
|
@ -42,7 +43,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
|||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||
|
||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface MemberHoverCardProps {
|
||||
/** The member name to look up */
|
||||
|
|
@ -131,7 +132,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
|
||||
const currentTask =
|
||||
isDisplayableCurrentTask(currentTaskCandidate) &&
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member,
|
||||
isTeamAlive,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
runtimeEntry,
|
||||
})
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
const presentationMember =
|
||||
member.currentTaskId && !currentTask
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
deriveReviewActivityTimerAnchor,
|
||||
deriveWorkActivityTimerAnchor,
|
||||
syncMemberActivityTimer,
|
||||
} from '@renderer/utils/memberActivityTimer';
|
||||
import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/utils/memberHelpers';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
|
@ -9,6 +14,7 @@ import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
|||
import { MemberCard } from './MemberCard';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
|
|
@ -22,6 +28,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
interface MemberListProps {
|
||||
teamName?: string;
|
||||
members: ResolvedTeamMember[];
|
||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||
taskMap?: Map<string, TeamTaskWithKanban>;
|
||||
|
|
@ -101,6 +108,45 @@ function areTaskStatusCountsMapsEquivalent(
|
|||
return true;
|
||||
}
|
||||
|
||||
function areTaskWorkIntervalsEquivalent(
|
||||
left: TeamTaskWithKanban['workIntervals'],
|
||||
right: TeamTaskWithKanban['workIntervals']
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((interval, index) => {
|
||||
const other = right[index];
|
||||
if (!other) return false;
|
||||
return interval.startedAt === other.startedAt && interval.completedAt === other.completedAt;
|
||||
});
|
||||
}
|
||||
|
||||
function areTaskHistoryEventsEquivalent(
|
||||
left: TeamTaskWithKanban['historyEvents'],
|
||||
right: TeamTaskWithKanban['historyEvents']
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((event, index) => {
|
||||
const other = right[index];
|
||||
if (!other) return false;
|
||||
const leftRow = event as unknown as Record<string, unknown>;
|
||||
const rightRow = other as unknown as Record<string, unknown>;
|
||||
return (
|
||||
event.id === other.id &&
|
||||
event.type === other.type &&
|
||||
event.timestamp === other.timestamp &&
|
||||
leftRow.actor === rightRow.actor &&
|
||||
leftRow.reviewer === rightRow.reviewer &&
|
||||
leftRow.from === rightRow.from &&
|
||||
leftRow.to === rightRow.to &&
|
||||
leftRow.status === rightRow.status
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function areMemberTaskMapsEquivalent(
|
||||
left: Map<string, TeamTaskWithKanban> | undefined,
|
||||
right: Map<string, TeamTaskWithKanban> | undefined
|
||||
|
|
@ -118,7 +164,9 @@ function areMemberTaskMapsEquivalent(
|
|||
leftTask.status !== rightTask.status ||
|
||||
leftTask.reviewer !== rightTask.reviewer ||
|
||||
leftTask.reviewState !== rightTask.reviewState ||
|
||||
leftTask.kanbanColumn !== rightTask.kanbanColumn
|
||||
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
|
||||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
|
||||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -243,6 +291,7 @@ function areMemberListPropsEqual(
|
|||
next: Readonly<MemberListProps>
|
||||
): boolean {
|
||||
return (
|
||||
prev.teamName === next.teamName &&
|
||||
areResolvedMembersEquivalent(prev.members, next.members) &&
|
||||
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
|
||||
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
|
||||
|
|
@ -270,6 +319,10 @@ interface MemberCardRowProps {
|
|||
memberColor: string;
|
||||
currentTask: TeamTaskWithKanban | null;
|
||||
reviewTask: TeamTaskWithKanban | null;
|
||||
currentTaskTimer: MemberActivityTimerAnchor | null;
|
||||
reviewTaskTimer: MemberActivityTimerAnchor | null;
|
||||
currentTaskTimerRunning: boolean;
|
||||
reviewTaskTimerRunning: boolean;
|
||||
awaitingReply: boolean;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
runtimeSummary?: string;
|
||||
|
|
@ -299,6 +352,10 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
memberColor,
|
||||
currentTask,
|
||||
reviewTask,
|
||||
currentTaskTimer,
|
||||
reviewTaskTimer,
|
||||
currentTaskTimerRunning,
|
||||
reviewTaskTimerRunning,
|
||||
awaitingReply,
|
||||
taskCounts,
|
||||
runtimeSummary,
|
||||
|
|
@ -346,6 +403,10 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
||||
currentTask={currentTask}
|
||||
reviewTask={reviewTask}
|
||||
currentTaskTimer={currentTaskTimer}
|
||||
reviewTaskTimer={reviewTaskTimer}
|
||||
currentTaskTimerRunning={currentTaskTimerRunning}
|
||||
reviewTaskTimerRunning={reviewTaskTimerRunning}
|
||||
isAwaitingReply={awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
runtimeSummary={runtimeSummary}
|
||||
|
|
@ -370,6 +431,7 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
});
|
||||
|
||||
export const MemberList = memo(function MemberList({
|
||||
teamName = '__unknown_team__',
|
||||
members,
|
||||
memberTaskCounts,
|
||||
taskMap,
|
||||
|
|
@ -434,6 +496,124 @@ export const MemberList = memo(function MemberList({
|
|||
return result;
|
||||
}, [taskMap]);
|
||||
|
||||
const isMemberActivityTimerRunning = useCallback(
|
||||
(
|
||||
spawnEntry: MemberSpawnStatusEntry | undefined,
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): boolean => {
|
||||
if (isTeamAlive === false) return false;
|
||||
if (
|
||||
spawnEntry?.status === 'offline' ||
|
||||
spawnEntry?.status === 'error' ||
|
||||
spawnEntry?.status === 'skipped'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
runtimeEntry?.livenessKind === 'shell_only' ||
|
||||
runtimeEntry?.livenessKind === 'registered_only' ||
|
||||
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
||||
runtimeEntry?.livenessKind === 'not_found'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[isTeamAlive]
|
||||
);
|
||||
|
||||
const getActivityTimerRunId = useCallback(
|
||||
(running: boolean): string | null => {
|
||||
if (!running) return null;
|
||||
return runtimeRunId ?? 'runtime:unknown';
|
||||
},
|
||||
[runtimeRunId]
|
||||
);
|
||||
|
||||
const withActivityTimerRunId = useCallback(
|
||||
(
|
||||
anchor: MemberActivityTimerAnchor | null,
|
||||
running: boolean
|
||||
): MemberActivityTimerAnchor | null => {
|
||||
if (!anchor) return null;
|
||||
return {
|
||||
...anchor,
|
||||
runId: getActivityTimerRunId(running),
|
||||
};
|
||||
},
|
||||
[getActivityTimerRunId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskMap) return;
|
||||
const nowMs = Date.now();
|
||||
for (const member of activeMembers) {
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||
const running = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
|
||||
const currentTaskCandidate = member.currentTaskId
|
||||
? (taskMap.get(member.currentTaskId) ?? null)
|
||||
: null;
|
||||
if (isDisplayableCurrentTask(currentTaskCandidate)) {
|
||||
const anchor = deriveWorkActivityTimerAnchor(currentTaskCandidate, {
|
||||
teamName,
|
||||
memberName: member.name,
|
||||
});
|
||||
if (anchor) {
|
||||
const visible =
|
||||
running &&
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member,
|
||||
isTeamAlive,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
runtimeEntry,
|
||||
});
|
||||
syncMemberActivityTimer({
|
||||
timerId: anchor.timerId,
|
||||
startedAtMs: anchor.startedAtMs,
|
||||
baseElapsedMs: anchor.baseElapsedMs,
|
||||
running: visible,
|
||||
runId: getActivityTimerRunId(visible),
|
||||
nowMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reviewTask = reviewTaskByMember.get(member.name) ?? null;
|
||||
if (reviewTask) {
|
||||
const anchor = deriveReviewActivityTimerAnchor(reviewTask, {
|
||||
teamName,
|
||||
memberName: member.name,
|
||||
});
|
||||
if (anchor) {
|
||||
syncMemberActivityTimer({
|
||||
timerId: anchor.timerId,
|
||||
startedAtMs: anchor.startedAtMs,
|
||||
baseElapsedMs: anchor.baseElapsedMs,
|
||||
running,
|
||||
runId: getActivityTimerRunId(running),
|
||||
nowMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeMembers,
|
||||
getActivityTimerRunId,
|
||||
isMemberActivityTimerRunning,
|
||||
isTeamAlive,
|
||||
memberRuntimeEntries,
|
||||
memberSpawnStatuses,
|
||||
reviewTaskByMember,
|
||||
taskMap,
|
||||
teamName,
|
||||
]);
|
||||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(
|
||||
member: ResolvedTeamMember,
|
||||
|
|
@ -457,16 +637,44 @@ export const MemberList = memo(function MemberList({
|
|||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div className={gridClass}>
|
||||
{activeMembers.map((member) => {
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||
const currentTaskCandidate =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
const currentTask =
|
||||
isDisplayableCurrentTask(currentTaskCandidate) &&
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member,
|
||||
isTeamAlive,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
runtimeEntry,
|
||||
})
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||
const reviewTask =
|
||||
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||
const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry);
|
||||
const currentTaskTimer = withActivityTimerRunId(
|
||||
currentTask
|
||||
? deriveWorkActivityTimerAnchor(currentTask, {
|
||||
teamName,
|
||||
memberName: member.name,
|
||||
})
|
||||
: null,
|
||||
activityTimerRunning
|
||||
);
|
||||
const reviewTaskTimer = withActivityTimerRunId(
|
||||
reviewTask
|
||||
? deriveReviewActivityTimerAnchor(reviewTask, {
|
||||
teamName,
|
||||
memberName: member.name,
|
||||
})
|
||||
: null,
|
||||
activityTimerRunning
|
||||
);
|
||||
return (
|
||||
<MemberCardRow
|
||||
key={member.name}
|
||||
|
|
@ -475,6 +683,10 @@ export const MemberList = memo(function MemberList({
|
|||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||
currentTask={currentTask}
|
||||
reviewTask={reviewTask}
|
||||
currentTaskTimer={currentTaskTimer}
|
||||
reviewTaskTimer={reviewTaskTimer}
|
||||
currentTaskTimerRunning={activityTimerRunning}
|
||||
reviewTaskTimerRunning={activityTimerRunning}
|
||||
awaitingReply={
|
||||
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
|
||||
}
|
||||
|
|
@ -516,6 +728,10 @@ export const MemberList = memo(function MemberList({
|
|||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||
currentTask={null}
|
||||
reviewTask={null}
|
||||
currentTaskTimer={null}
|
||||
reviewTaskTimer={null}
|
||||
currentTaskTimerRunning={false}
|
||||
reviewTaskTimerRunning={false}
|
||||
awaitingReply={false}
|
||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|||
interface TaskReadEntry {
|
||||
readIds: string[];
|
||||
lastUpdated: number;
|
||||
manualUnread?: boolean;
|
||||
}
|
||||
|
||||
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
|
||||
|
|
@ -116,9 +117,12 @@ export function getSnapshot(): ReadState {
|
|||
* Mark specific comment IDs as read for a given team/task.
|
||||
*/
|
||||
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
|
||||
if (commentIds.length === 0) return;
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key];
|
||||
if (commentIds.length === 0) {
|
||||
if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId);
|
||||
return;
|
||||
}
|
||||
const prevSet = new Set(prev?.readIds ?? []);
|
||||
let changed = false;
|
||||
for (const id of commentIds) {
|
||||
|
|
@ -127,7 +131,7 @@ export function markCommentsRead(teamName: string, taskId: string, commentIds: s
|
|||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
if (!changed && !prev?.manualUnread) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
|
|
@ -148,7 +152,7 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
|
|||
const prev = cache[key];
|
||||
// Update lastUpdated to at least this timestamp (for legacy migration support)
|
||||
const prevLastUpdated = prev?.lastUpdated ?? 0;
|
||||
if (latestTimestamp <= prevLastUpdated && prev) return;
|
||||
if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
|
|
@ -160,6 +164,43 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu
|
|||
scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually mark a task as unread even when it has no unread comments.
|
||||
*/
|
||||
export function markTaskUnread(teamName: string, taskId: string): void {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key];
|
||||
if (prev?.manualUnread) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
readIds: prev?.readIds ?? [],
|
||||
lastUpdated: Date.now(),
|
||||
manualUnread: true,
|
||||
},
|
||||
};
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only the manual unread marker. Comment read state is preserved.
|
||||
*/
|
||||
export function clearTaskManualUnread(teamName: string, taskId: string): void {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key];
|
||||
if (!prev?.manualUnread) return;
|
||||
cache = {
|
||||
...cache,
|
||||
[key]: {
|
||||
readIds: prev.readIds,
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
};
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unread comments for a task.
|
||||
* A comment is unread if its ID is NOT in the readIds set.
|
||||
|
|
@ -177,9 +218,9 @@ export function getUnreadCount(
|
|||
taskId: string,
|
||||
comments: { id?: string; createdAt: string }[]
|
||||
): number {
|
||||
if (!comments || comments.length === 0) return 0;
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const entry = readState[key];
|
||||
if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0;
|
||||
if (!entry) return comments.length;
|
||||
|
||||
const readSet = new Set(entry.readIds);
|
||||
|
|
@ -200,7 +241,7 @@ export function getUnreadCount(
|
|||
// Otherwise → unread
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
return entry.manualUnread && count === 0 ? 1 : count;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -272,6 +313,7 @@ async function load(): Promise<void> {
|
|||
merged[k] = {
|
||||
readIds: Array.from(mergedIds),
|
||||
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
|
||||
...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -290,6 +332,7 @@ async function load(): Promise<void> {
|
|||
merged[k] = {
|
||||
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
|
||||
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
|
||||
...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
|
||||
const ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL = false;
|
||||
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
||||
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
||||
|
|
@ -257,14 +258,20 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
});
|
||||
// This lightweight renderer-side poll keeps visible in-progress task badges fresh.
|
||||
// It is intentionally independent from the backend log-source tracking feature flag below.
|
||||
const inProgressChangePresencePollTimer = setInterval(() => {
|
||||
void pollVisibleTeamInProgressChangePresence();
|
||||
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
||||
cleanupFns.push(() => {
|
||||
clearInterval(inProgressChangePresencePollTimer);
|
||||
});
|
||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
||||
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||
// only after enough time or after opening a task popup, while still doing periodic
|
||||
// summary extraction work in the background. The replacement should check visible
|
||||
// tasks first, dedupe in-flight requests, keep popup/full diff requests higher
|
||||
// priority, and never render "unknown" as "no_changes".
|
||||
if (ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL) {
|
||||
const inProgressChangePresencePollTimer = setInterval(() => {
|
||||
void pollVisibleTeamInProgressChangePresence();
|
||||
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
||||
cleanupFns.push(() => {
|
||||
clearInterval(inProgressChangePresencePollTimer);
|
||||
});
|
||||
}
|
||||
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const teamLastRelevantActivityAt = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ function createAnthropicProviderStatus(
|
|||
}
|
||||
|
||||
describe('team model availability Codex catalog integration', () => {
|
||||
it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => {
|
||||
it('uses app-server catalog models with runtime-backed labels', () => {
|
||||
const providerStatus = createCodexProviderStatus(
|
||||
[
|
||||
{
|
||||
|
|
@ -171,12 +171,62 @@ describe('team model availability Codex catalog integration', () => {
|
|||
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||
value: 'gpt-5.5',
|
||||
label: '5.5',
|
||||
badgeLabel: 'New',
|
||||
availabilityStatus: 'available',
|
||||
});
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('orders GPT-5.5 first after the virtual default option', () => {
|
||||
const providerStatus = createCodexProviderStatus([
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
launchModel: 'gpt-5.4',
|
||||
displayName: 'GPT-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: '5.4',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
launchModel: 'gpt-5.5',
|
||||
displayName: 'GPT-5.5',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
defaultReasoningEffort: 'high',
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: '5.5',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
launchModel: 'gpt-5.2',
|
||||
displayName: 'GPT-5.2',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: '5.2',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAvailableTeamProviderModelOptions('codex', providerStatus).map((model) => model.value)
|
||||
).toEqual(['', 'gpt-5.5', 'gpt-5.4', 'gpt-5.2']);
|
||||
});
|
||||
|
||||
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
||||
const providerStatus = createCodexProviderStatus([
|
||||
{
|
||||
|
|
|
|||
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(
|
||||
member: ResolvedTeamMember,
|
||||
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-20251001': 'Haiku 4.5',
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
'gpt-5.5': 'GPT-5.5',
|
||||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||||
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
||||
|
|
@ -107,6 +108,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
|
|||
],
|
||||
codex: [
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5', badgeLabel: '5.5' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4', badgeLabel: '5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', badgeLabel: '5.4-mini' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', badgeLabel: '5.3-codex' },
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ describe('CodexLoginSessionManager', () => {
|
|||
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
||||
expect(openExternalMock).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getState().status).toBe('pending');
|
||||
expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth');
|
||||
});
|
||||
|
||||
it('cancels a login cleanly while the app-server session is still starting', async () => {
|
||||
|
|
@ -135,6 +136,7 @@ describe('CodexLoginSessionManager', () => {
|
|||
status: 'cancelled',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -170,6 +172,7 @@ describe('CodexLoginSessionManager', () => {
|
|||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const {
|
|||
status: 'idle' as CodexAccountLoginStatus,
|
||||
error: null as string | null,
|
||||
startedAt: null as string | null,
|
||||
authUrl: null as string | null,
|
||||
},
|
||||
},
|
||||
loginStateListeners: new Set<() => void>(),
|
||||
|
|
@ -857,6 +858,7 @@ describe('createCodexAccountFeature', () => {
|
|||
status: 'pending',
|
||||
error: null,
|
||||
startedAt: '2026-04-20T12:00:00.000Z',
|
||||
authUrl: 'https://chatgpt.com/auth',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -872,6 +874,7 @@ describe('createCodexAccountFeature', () => {
|
|||
expect(pendingSnapshot.login).toMatchObject({
|
||||
status: 'pending',
|
||||
startedAt: '2026-04-20T12:00:00.000Z',
|
||||
authUrl: 'https://chatgpt.com/auth',
|
||||
});
|
||||
expect(loginStartMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
|
|
@ -893,12 +896,14 @@ describe('createCodexAccountFeature', () => {
|
|||
status: 'pending',
|
||||
error: null,
|
||||
startedAt: '2026-04-20T12:00:00.000Z',
|
||||
authUrl: 'https://chatgpt.com/auth',
|
||||
});
|
||||
loginCancelMock.mockImplementation(() => {
|
||||
emitLoginState({
|
||||
status: 'cancelled',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
});
|
||||
for (const listener of loginSettledListeners) {
|
||||
listener();
|
||||
|
|
|
|||
|
|
@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const member: ResolvedTeamMember = {
|
||||
name: 'alice',
|
||||
|
|
@ -22,7 +22,7 @@ const storeState = {
|
|||
selectedTeamData: {
|
||||
members: [member],
|
||||
isAlive: true,
|
||||
tasks: [],
|
||||
tasks: [] as TeamTaskWithKanban[],
|
||||
},
|
||||
selectedTeamName: 'northstar-core',
|
||||
progress: null as Record<string, unknown> | null,
|
||||
|
|
@ -118,7 +118,18 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
|
||||
CurrentTaskIndicator: () => null,
|
||||
CurrentTaskIndicator: ({
|
||||
task,
|
||||
activityLabel,
|
||||
}: {
|
||||
task: TeamTaskWithKanban;
|
||||
activityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'span',
|
||||
{ 'data-testid': 'hover-current-task' },
|
||||
`${activityLabel ?? 'task'} ${task.id}`
|
||||
),
|
||||
}));
|
||||
|
||||
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
|
||||
|
|
@ -307,6 +318,45 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not show a working-on task when the member is offline', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const task: TeamTaskWithKanban = {
|
||||
id: 'task-active',
|
||||
subject: 'Active work',
|
||||
status: 'in_progress',
|
||||
};
|
||||
storeState.selectedTeamData.members = [{ ...member, currentTaskId: task.id }];
|
||||
storeState.selectedTeamData.tasks = [task];
|
||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||
status: 'offline',
|
||||
launchState: 'confirmed_alive',
|
||||
updatedAt: '2026-04-09T10:00:00.000Z',
|
||||
runtimeAlive: false,
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberHoverCard, {
|
||||
name: 'alice',
|
||||
children: React.createElement('button', { type: 'button' }, 'alice'),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="hover-current-task"]')).toBeNull();
|
||||
expect(host.textContent).not.toContain('working on');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies launch diagnostics with the active runtime run id only for launch errors', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,24 @@ function failedSpawnStatus(reason: string): MemberSpawnStatusEntry {
|
|||
};
|
||||
}
|
||||
|
||||
function offlineSpawnStatus(): MemberSpawnStatusEntry {
|
||||
return {
|
||||
status: 'offline',
|
||||
launchState: 'confirmed_alive',
|
||||
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function activeTask(id = 'task-active'): TeamTaskWithKanban {
|
||||
return {
|
||||
id,
|
||||
subject: 'Active task',
|
||||
status: 'in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
describe('MemberList spawn-status memoization', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
|
|
@ -240,6 +258,61 @@ describe('MemberList spawn-status memoization', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not pass active current tasks to cards while the whole team is offline', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const task = activeTask();
|
||||
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: false,
|
||||
taskMap: new Map([[task.id, task]]),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not pass active current tasks to cards for individually offline members', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const task = activeTask();
|
||||
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members,
|
||||
isTeamAlive: true,
|
||||
taskMap: new Map([[task.id, task]]),
|
||||
memberSpawnStatuses: new Map([['bob', offlineSpawnStatus()]]),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/Grap
|
|||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
const previewsByMember = new Map([
|
||||
const basePreviewsByMember = new Map([
|
||||
[
|
||||
'team-lead',
|
||||
{
|
||||
|
|
@ -43,6 +43,24 @@ const previewsByMember = new Map([
|
|||
preview: 'pnpm test',
|
||||
tone: 'warning' as const,
|
||||
},
|
||||
{
|
||||
id: 'preview-2',
|
||||
kind: 'tool_result' as const,
|
||||
provider: 'opencode_runtime' as const,
|
||||
timestamp: '2026-04-03T00:00:30.000Z',
|
||||
title: 'Send message error',
|
||||
preview: 'OpenCode tool failed without output',
|
||||
tone: 'error' as const,
|
||||
},
|
||||
{
|
||||
id: 'preview-3',
|
||||
kind: 'tool_result' as const,
|
||||
provider: 'opencode_runtime' as const,
|
||||
timestamp: '2026-04-03T00:00:40.000Z',
|
||||
title: 'Bash result',
|
||||
preview: 'Tests passed',
|
||||
tone: 'success' as const,
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||
warnings: [],
|
||||
|
|
@ -52,11 +70,12 @@ const previewsByMember = new Map([
|
|||
},
|
||||
],
|
||||
]);
|
||||
let mockedPreviewsByMember = basePreviewsByMember;
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||
useGraphMemberLogPreviews: () => ({
|
||||
previewsByMember,
|
||||
previewsByMember: mockedPreviewsByMember,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
|
|
@ -93,6 +112,7 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||
mockedPreviewsByMember = basePreviewsByMember;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -141,6 +161,20 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
button.textContent?.includes('pnpm test')
|
||||
);
|
||||
expect(row).not.toBeUndefined();
|
||||
expect(row?.querySelector('.float-left')).not.toBeNull();
|
||||
expect(row?.querySelector('.line-clamp-3')).toBeNull();
|
||||
expect(row?.textContent).toContain('pnpm test');
|
||||
|
||||
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('OpenCode tool failed')
|
||||
);
|
||||
expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull();
|
||||
|
||||
const resultRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Tests passed')
|
||||
);
|
||||
expect(resultRow?.textContent).toContain('Bash');
|
||||
expect(resultRow?.textContent).not.toContain('Bash result');
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
|
@ -166,6 +200,83 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('briefly highlights a newly appeared preview row', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const renderHud = (): void => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderHud();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const alicePreview = basePreviewsByMember.get('alice')!;
|
||||
mockedPreviewsByMember = new Map(basePreviewsByMember);
|
||||
mockedPreviewsByMember.set('alice', {
|
||||
...alicePreview,
|
||||
items: [
|
||||
{
|
||||
id: 'preview-new',
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:01:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'new compact log',
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
...alicePreview.items,
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderHud();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const newRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('new compact log')
|
||||
);
|
||||
expect(newRow?.className).toContain('border-sky-300/70');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(newRow?.className).not.toContain('border-sky-300/70');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import {
|
|||
validateStableSlotLayout,
|
||||
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
||||
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
|
||||
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants';
|
||||
import {
|
||||
KANBAN_ZONE,
|
||||
TASK_PILL,
|
||||
} from '../../../../packages/agent-graph/src/constants/canvas-constants';
|
||||
import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
|
||||
import {
|
||||
STABLE_SLOT_GEOMETRY,
|
||||
|
|
@ -171,7 +174,10 @@ describe('stable slot layout planner', () => {
|
|||
expect(frame).toBeDefined();
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
|
||||
const expectedKanbanTopInset =
|
||||
ACTIVITY_LANE.headerHeight + 4 - (KANBAN_ZONE.headerHeight - TASK_PILL.height / 2);
|
||||
expect(frame?.kanbanBandRect.top).toBe(frame!.boardBandRect.top + expectedKanbanTopInset);
|
||||
expect(frame?.kanbanBandRect.bottom).toBeLessThanOrEqual(frame!.boardBandRect.bottom);
|
||||
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);
|
||||
|
|
|
|||
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,
|
||||
getMemberRuntimeAdvisoryTone,
|
||||
isOpenCodeRelaunchActionable,
|
||||
shouldDisplayMemberCurrentTask,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -27,6 +28,73 @@ const member: ResolvedTeamMember = {
|
|||
};
|
||||
|
||||
describe('memberHelpers spawn-aware presence', () => {
|
||||
it('does not display current task labels for offline or terminal launch states', () => {
|
||||
expect(
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member: { ...member, currentTaskId: 'task-1' },
|
||||
isTeamAlive: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member: { ...member, currentTaskId: 'task-1' },
|
||||
isTeamAlive: true,
|
||||
spawnStatus: 'offline',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
spawnRuntimeAlive: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member: { ...member, currentTaskId: 'task-1' },
|
||||
isTeamAlive: true,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not display current task labels for runtime entries without a live agent runtime', () => {
|
||||
expect(
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member: { ...member, currentTaskId: 'task-1' },
|
||||
isTeamAlive: true,
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
providerId: 'opencode',
|
||||
livenessKind: 'stale_metadata',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps current task labels for confirmed online members', () => {
|
||||
expect(
|
||||
shouldDisplayMemberCurrentTask({
|
||||
member: { ...member, currentTaskId: 'task-1' },
|
||||
isTeamAlive: true,
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'confirmed_alive',
|
||||
spawnRuntimeAlive: true,
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: true,
|
||||
providerId: 'gemini',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows process-online teammates as online with a green dot', () => {
|
||||
expect(
|
||||
getSpawnAwarePresenceLabel(
|
||||
|
|
|
|||
Loading…
Reference in a new issue