fix(ci): apply frontend validation fixes
This commit is contained in:
parent
30a6e36976
commit
9d419626ef
42 changed files with 621 additions and 170 deletions
|
|
@ -25,11 +25,11 @@ import {
|
|||
} from '@shared/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import {
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
||||
import {
|
||||
buildInlineActivityEntries,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import type {
|
|||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 118;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 48;
|
||||
|
||||
interface StableRectLike {
|
||||
left: number;
|
||||
|
|
@ -155,6 +157,18 @@ function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): s
|
|||
return item.sourceLabel || 'Log event';
|
||||
}
|
||||
|
||||
function truncateCompactRowPreview(
|
||||
preview: string,
|
||||
displayTitle: string,
|
||||
relativeTime: string
|
||||
): string {
|
||||
const normalized = preview.replace(/\s+/g, ' ').trim();
|
||||
const metaLength = displayTitle.length + relativeTime.length + (relativeTime ? 2 : 1);
|
||||
const previewLimit = Math.max(COMPACT_ROW_MIN_PREVIEW_LIMIT, COMPACT_ROW_TEXT_LIMIT - metaLength);
|
||||
if (normalized.length <= previewLimit) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function setShellHidden(shell: HTMLDivElement): void {
|
||||
shell.style.opacity = '0';
|
||||
shell.style.pointerEvents = 'none';
|
||||
|
|
@ -405,42 +419,53 @@ export const GraphMemberLogPreviewHud = ({
|
|||
(memberName: string, item: MemberLogPreviewItem) => {
|
||||
const relativeTime = formatRelativeTime(item.timestamp);
|
||||
const displayTitle = compactDisplayTitle(item);
|
||||
const previewText = compactPreviewText(item, displayTitle);
|
||||
const fullPreviewText = compactPreviewText(item, displayTitle);
|
||||
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
|
||||
const titleText = relativeTime
|
||||
? `${displayTitle} ${relativeTime} ${previewText}`
|
||||
: `${displayTitle} ${previewText}`;
|
||||
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
||||
: `${displayTitle} ${fullPreviewText}`;
|
||||
const isHighlighted = highlightedItemIds.has(item.id);
|
||||
const isError = item.tone === 'error';
|
||||
const rowStateClassName = isHighlighted
|
||||
? isError
|
||||
? 'border-rose-300/75 bg-rose-950/35 shadow-[0_0_0_1px_rgba(253,164,175,0.30),0_0_18px_rgba(244,63,94,0.22)] hover:border-rose-300/80 hover:bg-rose-950/45'
|
||||
: '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)] hover:border-sky-300/75 hover:bg-[rgba(14,34,62,0.82)]'
|
||||
: isError
|
||||
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
||||
const iconClassName = isError
|
||||
? 'float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'inline-flex h-5 items-center align-top';
|
||||
const titleClassName = isError
|
||||
? 'text-[11px] font-medium leading-[18px] text-rose-100'
|
||||
: 'text-[11px] font-medium leading-[18px] text-slate-200';
|
||||
const timeClassName = isError
|
||||
? 'ml-1 text-[9px] font-normal leading-[18px] text-rose-300/70'
|
||||
: 'ml-1 text-[9px] font-normal leading-[18px] text-slate-500';
|
||||
const previewClassName = isError
|
||||
? 'ml-1 break-words align-top text-[10px] leading-[18px] text-rose-100/85'
|
||||
: 'ml-1 break-words align-top text-[10px] leading-[18px] text-slate-300/85';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
'block h-16 min-h-16 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)]',
|
||||
'block h-[68px] min-h-[68px] 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',
|
||||
rowStateClassName,
|
||||
].join(' ')}
|
||||
title={titleText}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className="align-top text-[11px] font-medium leading-4 text-slate-200">
|
||||
{displayTitle}
|
||||
</span>
|
||||
{relativeTime ? (
|
||||
<span className="ml-1 align-top text-[9px] font-normal leading-4 text-slate-500">
|
||||
{relativeTime}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-1 break-words align-top text-[10px] leading-4 text-slate-300/85">
|
||||
{previewText}
|
||||
<span className={headerClassName}>
|
||||
<span className={titleClassName}>{displayTitle}</span>
|
||||
{relativeTime ? <span className={timeClassName}>{relativeTime}</span> : null}
|
||||
</span>
|
||||
<span className={previewClassName}>{previewText}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
|
|
@ -494,7 +519,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-16 min-h-16 items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
className="flex h-[68px] min-h-[68px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{resolveEmptyText(preview, loading, error)}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||
|
||||
import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics';
|
||||
|
|
|
|||
|
|
@ -53,6 +53,48 @@ describe('memberLogPreviewExtractor', () => {
|
|||
expect(result.items[1]?.preview).toBe('older answer');
|
||||
});
|
||||
|
||||
it('marks assistant runtime error text as error tone without flagging normal text', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'api-error',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `API Error: 429
|
||||
|
||||
{"type":"error","error":{"type":"api_error","message":"Codex API error: 429"}}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'normal-error-word',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [{ type: 'text', text: 'Reviewed the error handling path and it is covered.' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'Assistant',
|
||||
preview: 'Reviewed the error handling path and it is covered.',
|
||||
tone: 'neutral',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'API error',
|
||||
tone: 'error',
|
||||
});
|
||||
expect(result.items[1]?.preview).toContain('API Error: 429');
|
||||
expect(result.items[1]?.preview).toContain('Codex API error: 429');
|
||||
expect(result.items[1]?.preview).not.toContain('{"type"');
|
||||
});
|
||||
|
||||
it('extracts readable inbound task and comment messages without agent-only blocks', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
|
|
@ -293,6 +335,96 @@ Reply to this comment using MCP tool task_add_comment.
|
|||
});
|
||||
});
|
||||
|
||||
it('accepts OpenCode canonical callId/toolName tool calls defensively', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'canonical-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
callId: 'fc-canonical',
|
||||
toolName: 'agent-teams_task_add_comment',
|
||||
input: {
|
||||
taskId: '1dcfefd2-e505-4b1f-af22-0227c0aa551a',
|
||||
text: 'Confirmed',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'canonical-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: '',
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'fc-canonical',
|
||||
content: JSON.stringify({
|
||||
taskId: '1dcfefd2-e505-4b1f-af22-0227c0aa551a',
|
||||
comment: {
|
||||
id: 'comment-1',
|
||||
author: 'jack',
|
||||
text: 'Confirmed',
|
||||
},
|
||||
}),
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment added',
|
||||
preview: 'Comment by jack on #1dcfefd2: Confirmed',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks nested structured error tool results without requiring is_error', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'api-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-api',
|
||||
content: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Codex API error: 429',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool error',
|
||||
preview: 'Codex API error: 429',
|
||||
tone: 'error',
|
||||
});
|
||||
expect(result.items[0]?.preview).not.toContain('{"type"');
|
||||
});
|
||||
|
||||
it('formats orphan comment result payloads without guessing add vs read semantics', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ export interface MemberLogPreviewParsedMessage {
|
|||
content: string | MemberLogPreviewContentBlock[];
|
||||
isMeta?: boolean;
|
||||
toolCalls?: readonly {
|
||||
id: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
callId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
isTask?: boolean;
|
||||
}[];
|
||||
|
|
@ -107,6 +109,20 @@ interface ToolUseContext {
|
|||
input?: unknown;
|
||||
}
|
||||
|
||||
interface ToolCallLike {
|
||||
id?: string;
|
||||
name?: string;
|
||||
callId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
interface NormalizedToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
function timestampMs(value: Date | string): number {
|
||||
const time = value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
return Number.isFinite(time) ? time : UNKNOWN_TIMESTAMP_MS;
|
||||
|
|
@ -207,6 +223,18 @@ function textFromTextContentBlocks(value: unknown): string | null {
|
|||
return text.trim().length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function textFromPreviewContent(content: string | MemberLogPreviewContentBlock[]): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.filter((block): block is Extract<MemberLogPreviewContentBlock, { type: 'text' }> => {
|
||||
return block.type === 'text' && typeof block.text === 'string';
|
||||
})
|
||||
.map((block) => block.text)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function unwrapAgentTeamsResponsePayload(payload: Record<string, unknown>): {
|
||||
payload: Record<string, unknown>;
|
||||
wrapperKey?: string;
|
||||
|
|
@ -256,6 +284,127 @@ function recordFromUnknown(value: unknown): Record<string, unknown> | null {
|
|||
return recordFromUnknownWithWrapper(value)?.payload ?? null;
|
||||
}
|
||||
|
||||
function unknownPayloadLooksLikeError(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(unknownPayloadLooksLikeError);
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const type = stringField(record, 'type')?.toLowerCase();
|
||||
if (type === 'error' || type?.endsWith('_error')) {
|
||||
return true;
|
||||
}
|
||||
if (record.ok === false || record.success === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const error = record.error;
|
||||
if (typeof error === 'string' && error.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (error === true) {
|
||||
return true;
|
||||
}
|
||||
const errorRecord = asRecord(error);
|
||||
if (
|
||||
errorRecord &&
|
||||
(stringField(errorRecord, 'type') ||
|
||||
stringField(errorRecord, 'message') ||
|
||||
stringField(errorRecord, 'code'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof record.errorMessage === 'string' && record.errorMessage.trim().length > 0;
|
||||
}
|
||||
|
||||
function payloadErrorMessage(payload: Record<string, unknown>): string | null {
|
||||
const direct =
|
||||
stringField(payload, 'error') ??
|
||||
stringField(payload, 'errorMessage') ??
|
||||
stringField(payload, 'message');
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const nestedError = asRecord(payload.error);
|
||||
if (!nestedError) {
|
||||
return null;
|
||||
}
|
||||
const nestedType = stringField(nestedError, 'type')?.replace(/_/g, ' ');
|
||||
const nestedCode = stringField(nestedError, 'code');
|
||||
const nestedMessage = stringField(nestedError, 'message');
|
||||
if (nestedMessage) {
|
||||
return nestedMessage;
|
||||
}
|
||||
if (nestedType && nestedCode) {
|
||||
return `${nestedType} ${nestedCode}`;
|
||||
}
|
||||
return nestedType ?? nestedCode;
|
||||
}
|
||||
|
||||
function parseJsonObjectFromText(value: string): Record<string, unknown> | null {
|
||||
const parsed = parseJsonLikeString(value);
|
||||
const direct = asRecord(parsed);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const jsonStart = value.indexOf('{');
|
||||
if (jsonStart <= 0) {
|
||||
return null;
|
||||
}
|
||||
return asRecord(parseJsonLikeString(value.slice(jsonStart)));
|
||||
}
|
||||
|
||||
function runtimeErrorTitle(value: string, payload: Record<string, unknown> | null): string {
|
||||
const labelMatch = /^(api error|runtime error|provider error|tool error)\b/i.exec(value);
|
||||
if (labelMatch?.[1]) {
|
||||
const label = labelMatch[1].toLowerCase();
|
||||
if (label === 'api error') return 'API error';
|
||||
return `${label[0]?.toUpperCase()}${label.slice(1)}`;
|
||||
}
|
||||
const type = stringField(payload, 'type') ?? stringField(asRecord(payload?.error), 'type');
|
||||
if (type?.toLowerCase().includes('api')) {
|
||||
return 'API error';
|
||||
}
|
||||
return 'Runtime error';
|
||||
}
|
||||
|
||||
function formatRuntimeErrorText(
|
||||
value: string,
|
||||
limit: number
|
||||
): (ValuePreview & { title: string }) | null {
|
||||
const compact = compactWhitespace(value);
|
||||
if (!compact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = parseJsonObjectFromText(compact);
|
||||
const hasErrorSignal =
|
||||
/^(api error|runtime error|provider error|tool error)\b/i.test(compact) ||
|
||||
/\b(api|codex|claude|openai|anthropic)\s+api\s+error\b/i.test(compact) ||
|
||||
/\b(api|codex|claude|openai|anthropic|provider)\s+error\s*:\s*\d{3}\b/i.test(compact) ||
|
||||
unknownPayloadLooksLikeError(payload);
|
||||
|
||||
if (!hasErrorSignal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = runtimeErrorTitle(compact, payload);
|
||||
const jsonStart = compact.indexOf('{');
|
||||
const header = jsonStart > 0 ? compact.slice(0, jsonStart).trim() : '';
|
||||
const payloadMessage = payload ? payloadErrorMessage(payload) : null;
|
||||
const text =
|
||||
payloadMessage && header && !header.toLowerCase().includes(payloadMessage.toLowerCase())
|
||||
? `${header} - ${payloadMessage}`
|
||||
: (payloadMessage ?? header);
|
||||
return { ...truncatePreview(text || 'Runtime error', limit), title };
|
||||
}
|
||||
|
||||
function findPriorityValue(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
|
|
@ -721,18 +870,8 @@ function formatRuntimePayload(
|
|||
}
|
||||
|
||||
function formatErrorPayload(payload: Record<string, unknown>): KnownPayloadPreview | null {
|
||||
const error =
|
||||
stringField(payload, 'error') ??
|
||||
stringField(payload, 'errorMessage') ??
|
||||
stringField(payload, 'message');
|
||||
if (
|
||||
error &&
|
||||
(payload.ok === false || payload.success === false || payload.error || payload.errorMessage)
|
||||
) {
|
||||
return { title: 'Tool error', text: error };
|
||||
}
|
||||
if (payload.ok === false || payload.success === false) {
|
||||
return { title: 'Tool error', text: 'Tool reported failure' };
|
||||
if (unknownPayloadLooksLikeError(payload)) {
|
||||
return { title: 'Tool error', text: payloadErrorMessage(payload) ?? 'Tool reported failure' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1059,16 +1198,7 @@ function extractTextPreview(
|
|||
content: string | MemberLogPreviewContentBlock[],
|
||||
textLimit: number
|
||||
): { preview: string; truncated: boolean } | null {
|
||||
if (typeof content === 'string') {
|
||||
const text = truncatePreview(content, textLimit);
|
||||
return text.preview.length > 0 ? text : null;
|
||||
}
|
||||
const text = content
|
||||
.filter((block): block is Extract<MemberLogPreviewContentBlock, { type: 'text' }> => {
|
||||
return block.type === 'text' && typeof block.text === 'string';
|
||||
})
|
||||
.map((block) => block.text)
|
||||
.join(' ');
|
||||
const text = textFromPreviewContent(content);
|
||||
const preview = truncatePreview(text, textLimit);
|
||||
return preview.preview.length > 0 ? preview : null;
|
||||
}
|
||||
|
|
@ -1204,12 +1334,35 @@ function isToolResultBlock(
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeToolCall(toolCall: ToolCallLike): NormalizedToolCall | null {
|
||||
const id =
|
||||
typeof toolCall.id === 'string'
|
||||
? toolCall.id.trim()
|
||||
: typeof toolCall.callId === 'string'
|
||||
? toolCall.callId.trim()
|
||||
: '';
|
||||
const name =
|
||||
typeof toolCall.name === 'string'
|
||||
? toolCall.name.trim()
|
||||
: typeof toolCall.toolName === 'string'
|
||||
? toolCall.toolName.trim()
|
||||
: '';
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
input: toolCall.input,
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolUseContexts(
|
||||
messages: readonly MemberLogPreviewParsedMessage[]
|
||||
): Map<string, ToolUseContext> {
|
||||
const contexts = new Map<string, ToolUseContext>();
|
||||
const addContext = (tool: { id: string; name: string; input?: unknown }): void => {
|
||||
const id = tool.id.trim();
|
||||
const addContext = (tool: NormalizedToolCall): void => {
|
||||
const id = tool.id;
|
||||
if (!id || contexts.has(id)) return;
|
||||
contexts.set(id, {
|
||||
id,
|
||||
|
|
@ -1220,7 +1373,12 @@ function buildToolUseContexts(
|
|||
};
|
||||
|
||||
for (const message of messages) {
|
||||
message.toolCalls?.forEach((toolCall) => addContext(toolCall));
|
||||
message.toolCalls?.forEach((toolCall) => {
|
||||
const normalized = normalizeToolCall(toolCall);
|
||||
if (normalized) {
|
||||
addContext(normalized);
|
||||
}
|
||||
});
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
message.content.forEach((block) => {
|
||||
if (!isToolUseBlock(block)) return;
|
||||
|
|
@ -1375,7 +1533,12 @@ function collectToolUseCandidates(input: {
|
|||
);
|
||||
};
|
||||
|
||||
input.message.toolCalls?.forEach((toolCall, index) => addTool(toolCall, 100 + index));
|
||||
input.message.toolCalls?.forEach((toolCall, index) => {
|
||||
const normalized = normalizeToolCall(toolCall);
|
||||
if (normalized) {
|
||||
addTool(normalized, 100 + index);
|
||||
}
|
||||
});
|
||||
if (Array.isArray(input.message.content)) {
|
||||
input.message.content.forEach((block, index) => {
|
||||
if (!isToolUseBlock(block)) return;
|
||||
|
|
@ -1527,6 +1690,10 @@ export function extractMemberLogPreviewItems(
|
|||
if (role === 'assistant') {
|
||||
const textPreview = extractTextPreview(message.content, textLimit);
|
||||
if (textPreview) {
|
||||
const runtimeErrorPreview = formatRuntimeErrorText(
|
||||
textFromPreviewContent(message.content),
|
||||
textLimit
|
||||
);
|
||||
candidates.push(
|
||||
buildCandidate({
|
||||
provider: input.provider,
|
||||
|
|
@ -1535,14 +1702,14 @@ export function extractMemberLogPreviewItems(
|
|||
messageIndex,
|
||||
blockIndex: 10,
|
||||
kind: 'text',
|
||||
title: 'Assistant',
|
||||
preview: textPreview.preview,
|
||||
tone: 'neutral',
|
||||
title: runtimeErrorPreview?.title ?? 'Assistant',
|
||||
preview: runtimeErrorPreview?.preview ?? textPreview.preview,
|
||||
tone: runtimeErrorPreview ? 'error' : 'neutral',
|
||||
sourceLabel: input.sourceLabel,
|
||||
sessionId: input.sessionId ?? message.sessionId,
|
||||
laneId: input.laneId,
|
||||
token: 'assistant-text',
|
||||
textTruncated: textPreview.truncated,
|
||||
textTruncated: runtimeErrorPreview?.truncated ?? textPreview.truncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {
|
|||
RuntimeTurnSettledIngestor,
|
||||
type RuntimeTurnSettledTargetResolverPort,
|
||||
} from '../../core/application';
|
||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { MemberWorkSyncTaskImpactResolver } from '../adapters/input/MemberWorkSyncTaskImpactResolver';
|
||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
|
@ -18,8 +16,9 @@ import type { ServiceContext } from '@main/services';
|
|||
|
||||
const CODEX_SESSION_FILE_PARSE_LIMIT = 500;
|
||||
const CODEX_PROJECT_CANDIDATE_LIMIT = 40;
|
||||
const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 3_500;
|
||||
const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 8_000;
|
||||
const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24;
|
||||
const CODEX_SESSION_METADATA_READ_LIMIT_BYTES = 128 * 1024;
|
||||
|
||||
interface CodexSessionFileEntry {
|
||||
filePath: string;
|
||||
|
|
@ -45,6 +44,14 @@ interface CodexSessionProjectSnapshot {
|
|||
branchName?: string;
|
||||
}
|
||||
|
||||
interface CodexSessionMetadata {
|
||||
cwd: string;
|
||||
source: unknown;
|
||||
payloadTimestamp?: unknown;
|
||||
eventTimestamp?: unknown;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
}
|
||||
|
|
@ -66,23 +73,47 @@ function getCodexHome(codexHome?: string): string {
|
|||
return codexHome?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const lines = readline.createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
function extractJsonStringField(input: string, fieldName: string): string {
|
||||
const pattern = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
|
||||
const match = pattern.exec(input);
|
||||
if (!match) return '';
|
||||
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
return line;
|
||||
}
|
||||
return null;
|
||||
return JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionMetadataPrefix(firstLine: string): CodexSessionMetadata | null {
|
||||
const cwd = extractJsonStringField(firstLine, 'cwd').trim();
|
||||
const source = extractJsonStringField(firstLine, 'source').trim();
|
||||
if (!cwd || !source) return null;
|
||||
|
||||
return {
|
||||
cwd,
|
||||
source,
|
||||
payloadTimestamp: extractJsonStringField(firstLine, 'timestamp'),
|
||||
eventTimestamp: extractJsonStringField(firstLine, 'timestamp'),
|
||||
branchName: extractJsonStringField(firstLine, 'branch').trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
let handle: fs.FileHandle | null = null;
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
const buffer = Buffer.allocUnsafe(CODEX_SESSION_METADATA_READ_LIMIT_BYTES);
|
||||
const result = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (result.bytesRead <= 0) return null;
|
||||
|
||||
const newlineIndex = buffer.subarray(0, result.bytesRead).indexOf(0x0a);
|
||||
const endIndex = newlineIndex >= 0 ? newlineIndex : result.bytesRead;
|
||||
return buffer.toString('utf8', 0, endIndex);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
lines.close();
|
||||
stream.destroy();
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,28 +161,36 @@ function parseSessionSnapshot(
|
|||
firstLine: string,
|
||||
mtimeMs: number
|
||||
): CodexSessionProjectSnapshot | null {
|
||||
let event: CodexSessionEvent;
|
||||
let metadata: CodexSessionMetadata | null = null;
|
||||
try {
|
||||
event = JSON.parse(firstLine) as CodexSessionEvent;
|
||||
const event = JSON.parse(firstLine) as CodexSessionEvent;
|
||||
metadata = {
|
||||
cwd: typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : '',
|
||||
source: event.payload?.source,
|
||||
payloadTimestamp: event.payload?.timestamp,
|
||||
eventTimestamp: event.timestamp,
|
||||
branchName:
|
||||
typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
metadata = parseSessionMetadataPrefix(firstLine);
|
||||
}
|
||||
|
||||
const cwd = typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : '';
|
||||
if (!cwd || !isInteractiveSource(event.payload?.source) || isEphemeralProjectPath(cwd)) {
|
||||
const cwd = metadata?.cwd ?? '';
|
||||
if (!metadata || !cwd || !isInteractiveSource(metadata.source) || isEphemeralProjectPath(cwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
mtimeMs || normalizeTimestamp(event.payload?.timestamp) || normalizeTimestamp(event.timestamp);
|
||||
const branchName =
|
||||
typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : '';
|
||||
mtimeMs ||
|
||||
normalizeTimestamp(metadata.payloadTimestamp) ||
|
||||
normalizeTimestamp(metadata.eventTimestamp);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
source: event.payload?.source,
|
||||
source: metadata.source,
|
||||
lastActivityAt: timestamp,
|
||||
branchName: branchName || undefined,
|
||||
branchName: metadata.branchName || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -356,10 +356,7 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath);
|
||||
if (
|
||||
existingRequest &&
|
||||
existingRequest.generationAtStart === TeamConfigReader.listTeamsGeneration
|
||||
) {
|
||||
if (existingRequest?.generationAtStart === TeamConfigReader.listTeamsGeneration) {
|
||||
return cloneTeamSummaries(await existingRequest.promise);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
|||
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||
|
||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import {
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
type OpenCodePromptDeliveryLedgerRecord,
|
||||
|
|
@ -13,6 +11,8 @@ import {
|
|||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
|
||||
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ export class TeamMessageFeedService {
|
|||
|
||||
const existingRequest = this.inFlightByTeam.get(teamName);
|
||||
const generationAtStart = this.getGeneration(teamName);
|
||||
if (existingRequest && existingRequest.generationAtStart === generationAtStart) {
|
||||
if (existingRequest?.generationAtStart === generationAtStart) {
|
||||
return existingRequest.promise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,12 +84,6 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskDeleted,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
isTeamInternalControlMessageText,
|
||||
stripExactInternalControlEchoPrefix,
|
||||
|
|
@ -104,6 +98,12 @@ import {
|
|||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskDeleted,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
extractToolPreview,
|
||||
extractToolResultPreview,
|
||||
|
|
@ -169,10 +169,6 @@ import {
|
|||
type OpenCodePromptDeliveryLedgerStore,
|
||||
type OpenCodePromptDeliveryStatus,
|
||||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
import {
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason,
|
||||
selectOpenCodeRuntimeDeliveryReason,
|
||||
} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||
import {
|
||||
decideOpenCodePromptDeliveryRepair,
|
||||
type OpenCodePromptDeliveryHardFailureKind,
|
||||
|
|
@ -189,6 +185,10 @@ import {
|
|||
OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY,
|
||||
type OpenCodeVisibleReplyProof,
|
||||
} from './opencode/delivery/OpenCodePromptDeliveryWatchdog';
|
||||
import {
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason,
|
||||
selectOpenCodeRuntimeDeliveryReason,
|
||||
} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
||||
import {
|
||||
type RuntimeDeliveryDestinationPort,
|
||||
|
|
|
|||
|
|
@ -468,10 +468,7 @@ export class TeamTaskReader {
|
|||
return cloned;
|
||||
}
|
||||
|
||||
if (
|
||||
TeamTaskReader.allTasksInFlight &&
|
||||
TeamTaskReader.allTasksInFlight.generationAtStart === TeamTaskReader.allTasksGeneration
|
||||
) {
|
||||
if (TeamTaskReader.allTasksInFlight?.generationAtStart === TeamTaskReader.allTasksGeneration) {
|
||||
const waitedAt = Date.now();
|
||||
const tasks = await TeamTaskReader.allTasksInFlight.promise;
|
||||
const cloned = cloneTasks(tasks);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
|
||||
import type {
|
||||
OpenCodeBridgeCommandName,
|
||||
OpenCodeBridgeHandshake,
|
||||
OpenCodeBridgePeerIdentity,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import type {
|
||||
OpenCodeBridgeCommandExecutor,
|
||||
OpenCodeBridgeHandshakePort,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ import {
|
|||
validateRuntimeStoreManifest,
|
||||
} from './RuntimeStoreManifest';
|
||||
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||
import type {
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
} from '@shared/types/team';
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||
|
||||
const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader');
|
||||
|
||||
|
|
|
|||
|
|
@ -596,7 +596,7 @@ function normalizeAppManagedBootstrapCandidate(
|
|||
runtimeSessionId?: string;
|
||||
}
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || value.schemaVersion !== 1 || value.source !== 'app_managed_bootstrap') {
|
||||
if (value?.schemaVersion !== 1 || value.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscrip
|
|||
import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates';
|
||||
import { TeamKanbanManager } from '../TeamKanbanManager';
|
||||
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '../TeamTaskReader';
|
||||
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
|
||||
import { TeamTaskReader } from '../TeamTaskReader';
|
||||
|
||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ export class BoardTaskActivityRecordSource {
|
|||
private async getTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||
const generation = this.getTranscriptDiscoveryGeneration(teamName);
|
||||
const cached = this.indexCache.get(teamName);
|
||||
if (cached && cached.generation === generation && cached.expiresAt > Date.now()) {
|
||||
if (cached?.generation === generation && cached.expiresAt > Date.now()) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingInFlight = this.indexInFlight.get(teamName);
|
||||
if (existingInFlight && existingInFlight.generation === generation) {
|
||||
if (existingInFlight?.generation === generation) {
|
||||
return await existingInFlight.promise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,12 +95,12 @@ export class TeamTranscriptSourceLocator {
|
|||
|
||||
const generation = this.getGeneration(teamName);
|
||||
const cached = this.contextCache.get(teamName);
|
||||
if (cached && cached.generation === generation && cached.expiresAt > Date.now()) {
|
||||
if (cached?.generation === generation && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const inFlight = this.contextInFlight.get(teamName);
|
||||
if (inFlight && inFlight.generation === generation) {
|
||||
if (inFlight?.generation === generation) {
|
||||
return await inFlight.promise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ export class CodexNativeTraceProjector {
|
|||
>();
|
||||
for (const event of run.events) {
|
||||
const projection = event.projection;
|
||||
if (!projection || projection.toolSource !== 'native') {
|
||||
if (projection?.toolSource !== 'native') {
|
||||
continue;
|
||||
}
|
||||
if (!projection.itemId) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
|||
|
||||
export {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
isTeamTaskTerminalForActionableWork,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import {
|
||||
formatProviderStatusText,
|
||||
getProviderConnectionModeSummary,
|
||||
|
|
@ -29,7 +30,6 @@ 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';
|
||||
|
|
@ -373,21 +373,6 @@ const ProviderDetailSkeleton = (): React.JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
const OpenCodeBetaBadge = (): React.JSX.Element => {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-4 shrink-0 items-center rounded border px-1.5 text-[9px] font-semibold uppercase leading-none"
|
||||
style={{
|
||||
borderColor: 'rgba(251, 191, 36, 0.32)',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.12)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
beta
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
|
|
@ -781,7 +766,6 @@ const InstalledBanner = ({
|
|||
? getProviderLabel(provider.providerId)
|
||||
: provider.displayName}
|
||||
</span>
|
||||
{provider.providerId === 'opencode' ? <OpenCodeBetaBadge /> : null}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ interface CodexLoginLinkCopyButtonProps {
|
|||
size?: 'xs' | 'sm';
|
||||
}
|
||||
|
||||
export function CodexLoginLinkCopyButton({
|
||||
export const CodexLoginLinkCopyButton = ({
|
||||
authUrl,
|
||||
disabled = false,
|
||||
size = 'sm',
|
||||
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null {
|
||||
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -53,4 +53,4 @@ export function CodexLoginLinkCopyButton({
|
|||
{copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy link'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import {
|
|||
resolveCodexRuntimeSelection,
|
||||
} from '@features/codex-runtime-profile/renderer';
|
||||
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -40,7 +41,6 @@ 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';
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import { useStore } from '@renderer/store';
|
|||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ import {
|
|||
getKnownSlashCommand,
|
||||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function shouldShowCodexReconnectPrompt({
|
|||
);
|
||||
}
|
||||
|
||||
export function CodexReconnectPrompt({
|
||||
export const CodexReconnectPrompt = ({
|
||||
authUrl,
|
||||
reconnectBusy,
|
||||
onReconnect,
|
||||
|
|
@ -67,7 +67,7 @@ export function CodexReconnectPrompt({
|
|||
authUrl: string | null;
|
||||
reconnectBusy: boolean;
|
||||
onReconnect: () => void;
|
||||
}): React.JSX.Element {
|
||||
}): React.JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className="mt-2 rounded-md border px-2.5 py-2"
|
||||
|
|
@ -98,4 +98,4 @@ export function CodexReconnectPrompt({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ import {
|
|||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
|
|||
|
|
@ -88,15 +88,15 @@ 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 { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
resolveProviderScopedMemberModel,
|
||||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
|||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
import {
|
||||
|
|
@ -100,8 +100,8 @@ import {
|
|||
resolveProviderScopedMemberModel,
|
||||
} from './memberModelScope';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@ function getSourceLabel(source: DashboardRecentProjectSource): string {
|
|||
}
|
||||
}
|
||||
|
||||
function ProjectSourceBadge({
|
||||
const ProjectSourceBadge = ({
|
||||
source,
|
||||
}: {
|
||||
source?: DashboardRecentProjectSource;
|
||||
}): React.JSX.Element | null {
|
||||
}): React.JSX.Element | null => {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ function ProjectSourceBadge({
|
|||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export type CwdMode = 'project' | 'custom';
|
||||
|
||||
|
|
|
|||
|
|
@ -54,16 +54,16 @@ import {
|
|||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
deriveTaskDisplayId,
|
||||
formatTaskDisplayLabel,
|
||||
taskMatchesRef,
|
||||
} from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
AlignLeft,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
import type { Project } from '@shared/types';
|
||||
|
||||
export interface ProjectPathProject extends Project {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import {
|
|||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import {
|
|||
REVIEW_STATE_DISPLAY,
|
||||
TASK_STATUS_LABELS,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -779,7 +779,7 @@ function isQueuedOpenCodeLaunch(
|
|||
|
||||
// Only label lanes as queued before runtime evidence appears. Once the
|
||||
// backend has any liveness signal, show the exact runtime state instead.
|
||||
return runtimeEntry == null || runtimeEntry.livenessKind == null;
|
||||
return runtimeEntry?.livenessKind == null;
|
||||
}
|
||||
|
||||
function hasElapsedSinceIso(
|
||||
|
|
|
|||
|
|
@ -1133,8 +1133,8 @@ export interface RetryFailedOpenCodeSecondaryLanesResult {
|
|||
attempted: string[];
|
||||
confirmed: string[];
|
||||
pending: string[];
|
||||
failed: Array<{ memberName: string; error: string }>;
|
||||
skipped: Array<{ memberName: string; reason: string }>;
|
||||
failed: { memberName: string; error: string }[];
|
||||
skipped: { memberName: string; reason: string }[];
|
||||
}
|
||||
|
||||
export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ async function writeRollout(
|
|||
source?: string;
|
||||
timestamp?: string;
|
||||
branch?: string;
|
||||
metadataPadding?: string;
|
||||
},
|
||||
mtime: Date
|
||||
): Promise<void> {
|
||||
|
|
@ -43,6 +44,9 @@ async function writeRollout(
|
|||
cwd: payload.cwd,
|
||||
source: payload.source ?? 'cli',
|
||||
git: payload.branch ? { branch: payload.branch } : undefined,
|
||||
...(payload.metadataPadding
|
||||
? { base_instructions: { text: payload.metadataPadding } }
|
||||
: {}),
|
||||
},
|
||||
})}\n${'x'.repeat(1024)}`,
|
||||
'utf8'
|
||||
|
|
@ -110,6 +114,40 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
|
|||
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
|
||||
});
|
||||
|
||||
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
|
||||
const codexHome = path.join(tempDir, '.codex');
|
||||
const logger = createLogger();
|
||||
const identityResolver = {
|
||||
resolve: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
const updatedAt = new Date('2026-04-14T12:00:00.000Z');
|
||||
await writeRollout(
|
||||
path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-large.jsonl'),
|
||||
{
|
||||
cwd: '/Users/test/projects/large',
|
||||
metadataPadding: 'x'.repeat(160_000),
|
||||
},
|
||||
updatedAt
|
||||
);
|
||||
|
||||
const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
identityResolver,
|
||||
logger,
|
||||
codexHome,
|
||||
});
|
||||
|
||||
const result = await adapter.list();
|
||||
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
primaryPath: '/Users/test/projects/large',
|
||||
sourceKind: 'codex',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates sessions by cwd and keeps the newest activity', async () => {
|
||||
const codexHome = path.join(tempDir, '.codex');
|
||||
const logger = createLogger();
|
||||
|
|
|
|||
|
|
@ -164,14 +164,16 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
expect(row).not.toBeUndefined();
|
||||
expect(row?.querySelector('.float-left')).not.toBeNull();
|
||||
expect(row?.querySelector('.line-clamp-3')).toBeNull();
|
||||
expect(row?.className).toContain('h-16');
|
||||
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-4');
|
||||
expect(row?.className).toContain('h-[68px]');
|
||||
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-[18px]');
|
||||
expect(row?.textContent).toContain('pnpm test');
|
||||
|
||||
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('OpenCode tool failed')
|
||||
);
|
||||
expect(errorRow?.className).toContain('border-rose-400/35');
|
||||
expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull();
|
||||
expect(errorRow?.querySelector('.text-rose-100')).not.toBeNull();
|
||||
|
||||
const resultRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Tests passed')
|
||||
|
|
@ -203,6 +205,73 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('caps long visible rows while preserving the full preview in the title', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const longPreview =
|
||||
'to team-lead inbox - #68a3a8cc blocked by dependencies and needs a follow-up investigation before merge final-token';
|
||||
const alicePreview = basePreviewsByMember.get('alice')!;
|
||||
mockedPreviewsByMember = new Map(basePreviewsByMember);
|
||||
mockedPreviewsByMember.set('alice', {
|
||||
...alicePreview,
|
||||
items: [
|
||||
{
|
||||
id: 'preview-long',
|
||||
kind: 'tool_use' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Send message',
|
||||
preview: longPreview,
|
||||
tone: 'warning' as const,
|
||||
},
|
||||
],
|
||||
overflowCount: 0,
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
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 Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Send message')
|
||||
);
|
||||
|
||||
expect(row?.textContent).toContain('...');
|
||||
expect(row?.textContent).not.toContain('final-token');
|
||||
expect(row?.getAttribute('title')).toContain('final-token');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('briefly highlights a newly appeared preview row', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
|
|
|
|||
Loading…
Reference in a new issue