fix(team): stabilize launch previews and codex reconnect
This commit is contained in:
parent
472a1501ad
commit
8d06ee81c2
12 changed files with 382 additions and 49 deletions
|
|
@ -184,6 +184,7 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
|
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
|
||||||
const previewsByMemberRef = useRef(previewsByMember);
|
const previewsByMemberRef = useRef(previewsByMember);
|
||||||
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
||||||
|
const activeRequestKeyByMemberRef = useRef(new Map<string, string>());
|
||||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const teamNameRef = useRef(input.teamName);
|
const teamNameRef = useRef(input.teamName);
|
||||||
|
|
||||||
|
|
@ -196,6 +197,7 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
teamNameRef.current = input.teamName;
|
teamNameRef.current = input.teamName;
|
||||||
cacheRef.current.clear();
|
cacheRef.current.clear();
|
||||||
inFlightRef.current.clear();
|
inFlightRef.current.clear();
|
||||||
|
activeRequestKeyByMemberRef.current.clear();
|
||||||
setPreviewsByMember(new Map());
|
setPreviewsByMember(new Map());
|
||||||
}
|
}
|
||||||
if (!enabled || memberNames.length === 0) {
|
if (!enabled || memberNames.length === 0) {
|
||||||
|
|
@ -261,6 +263,9 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
forceRefresh: options?.forceRefresh,
|
forceRefresh: options?.forceRefresh,
|
||||||
});
|
});
|
||||||
const requestTeamName = input.teamName;
|
const requestTeamName = input.teamName;
|
||||||
|
for (const memberName of membersToRequest) {
|
||||||
|
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (!options?.background && hasMissingPreview) {
|
if (!options?.background && hasMissingPreview) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -310,7 +315,15 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
if (teamNameRef.current !== requestTeamName) {
|
if (teamNameRef.current !== requestTeamName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviewsByMember((current) => mergeMemberPreviews(current, members.values()));
|
const currentMembers = Array.from(members.values()).filter((member) => {
|
||||||
|
return (
|
||||||
|
activeRequestKeyByMemberRef.current.get(normalizeMemberName(member.memberName)) ===
|
||||||
|
requestKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (currentMembers.length > 0) {
|
||||||
|
setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers));
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (teamNameRef.current !== requestTeamName) {
|
if (teamNameRef.current !== requestTeamName) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
type CodexAppServerLoginAccountResponse,
|
type CodexAppServerLoginAccountResponse,
|
||||||
type CodexAppServerSession,
|
type CodexAppServerSession,
|
||||||
} from '@main/services/infrastructure/codexAppServer';
|
} from '@main/services/infrastructure/codexAppServer';
|
||||||
import { shell } from 'electron';
|
|
||||||
|
|
||||||
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
|
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
|
||||||
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
||||||
|
|
@ -139,8 +138,6 @@ export class CodexLoginSessionManager {
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: authUrl.toString(),
|
authUrl: authUrl.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await shell.openExternal(authUrl.toString());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wasAbandonedDuringStart =
|
const wasAbandonedDuringStart =
|
||||||
this.pendingStartToken !== startToken &&
|
this.pendingStartToken !== startToken &&
|
||||||
|
|
|
||||||
|
|
@ -537,8 +537,7 @@ Reply to this comment using MCP tool task_add_comment.
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
kind: 'tool_result',
|
kind: 'tool_result',
|
||||||
title: 'Read task error',
|
title: 'Read task error',
|
||||||
preview:
|
preview: "Tool 'task_get' execution failed: Task not found: 211e430b",
|
||||||
"Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f",
|
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -638,6 +637,39 @@ Reply to this comment using MCP tool task_add_comment.
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not repeat generic comment titles in compact previews', () => {
|
||||||
|
const result = extractMemberLogPreviewItems({
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
maxItems: 3,
|
||||||
|
textLimit: 160,
|
||||||
|
messages: [
|
||||||
|
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({
|
||||||
|
comment: {
|
||||||
|
text: 'Focused checks passed.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
kind: 'tool_result',
|
||||||
|
title: 'Comment',
|
||||||
|
preview: 'Focused checks passed.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('distinguishes read-comment results from add-comment results', () => {
|
it('distinguishes read-comment results from add-comment results', () => {
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'claude_transcript',
|
provider: 'claude_transcript',
|
||||||
|
|
@ -1694,6 +1726,115 @@ Reply to this comment using MCP tool task_add_comment.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cleans tagged file tool output and shortens absolute paths for compact rows', () => {
|
||||||
|
const result = extractMemberLogPreviewItems({
|
||||||
|
provider: 'opencode_runtime',
|
||||||
|
maxItems: 3,
|
||||||
|
textLimit: 160,
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
uuid: 'read-call',
|
||||||
|
timestamp: '2026-04-01T10:00:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-read',
|
||||||
|
name: 'Read',
|
||||||
|
input: {
|
||||||
|
file_path: '/Users/belief/dev/projects/demo/app/page.tsx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
uuid: 'read-result',
|
||||||
|
type: 'user',
|
||||||
|
role: 'user',
|
||||||
|
timestamp: '2026-04-01T10:01:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-read',
|
||||||
|
content: `<path>/Users/belief/dev/projects/demo/app/page.tsx</path>
|
||||||
|
<type>file</type>
|
||||||
|
<content>
|
||||||
|
1: export default function Page() {
|
||||||
|
2: return null;
|
||||||
|
3: }
|
||||||
|
|
||||||
|
(End of file - total 3 lines)
|
||||||
|
</content>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
kind: 'tool_result',
|
||||||
|
title: 'Read result',
|
||||||
|
preview: 'demo/app/page.tsx - export default function Page() { return null; }',
|
||||||
|
});
|
||||||
|
expect(result.items[0]?.preview).not.toContain('/Users/belief');
|
||||||
|
expect(result.items[0]?.preview).not.toContain('<path>');
|
||||||
|
expect(result.items[0]?.preview).not.toContain('1:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans tagged directory tool output without repeating absolute paths', () => {
|
||||||
|
const result = extractMemberLogPreviewItems({
|
||||||
|
provider: 'opencode_runtime',
|
||||||
|
maxItems: 3,
|
||||||
|
textLimit: 160,
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
uuid: 'read-call',
|
||||||
|
timestamp: '2026-04-01T10:00:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-read',
|
||||||
|
name: 'Read',
|
||||||
|
input: {
|
||||||
|
file_path: '/Users/belief/dev/projects/demo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
uuid: 'read-result',
|
||||||
|
type: 'user',
|
||||||
|
role: 'user',
|
||||||
|
timestamp: '2026-04-01T10:01:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-read',
|
||||||
|
content: `<path>/Users/belief/dev/projects/demo</path>
|
||||||
|
<type>directory</type>
|
||||||
|
<entries>
|
||||||
|
app/
|
||||||
|
package.json
|
||||||
|
README.md
|
||||||
|
|
||||||
|
(3 entries)
|
||||||
|
</entries>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
kind: 'tool_result',
|
||||||
|
title: 'Read result',
|
||||||
|
preview: 'demo - directory app/ package.json README.md',
|
||||||
|
});
|
||||||
|
expect(result.items[0]?.preview).not.toContain('/Users/belief');
|
||||||
|
expect(result.items[0]?.preview).not.toContain('(3 entries)');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not label arbitrary message fields as sent messages', () => {
|
it('does not label arbitrary message fields as sent messages', () => {
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'opencode_runtime',
|
provider: 'opencode_runtime',
|
||||||
|
|
|
||||||
|
|
@ -191,8 +191,15 @@ function parseJsonLikeString(value: string): unknown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortenLongIdsForPreview(value: string): string {
|
||||||
|
return value.replace(
|
||||||
|
/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
|
||||||
|
'$1'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } {
|
function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } {
|
||||||
const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
|
const compact = shortenLongIdsForPreview(compactWhitespace(removeHiddenInstructionBlocks(value)));
|
||||||
if (compact.length <= limit) {
|
if (compact.length <= limit) {
|
||||||
return { preview: compact, truncated: false };
|
return { preview: compact, truncated: false };
|
||||||
}
|
}
|
||||||
|
|
@ -533,6 +540,30 @@ function formatShellResultContext(toolContext: ToolUseContext | undefined): stri
|
||||||
return stringField(input, 'description') ?? stringField(input, 'command');
|
return stringField(input, 'description') ?? stringField(input, 'command');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortenPathForPreview(value: string): string {
|
||||||
|
const compact = compactWhitespace(value);
|
||||||
|
if (!compact) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const normalized = compact.replace(/\\/g, '/');
|
||||||
|
if (!normalized.startsWith('/') && normalized.length <= 56) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.length <= 3) {
|
||||||
|
return normalized.startsWith('/') ? parts.join('/') : normalized;
|
||||||
|
}
|
||||||
|
const projectsIndex = parts.lastIndexOf('projects');
|
||||||
|
if (projectsIndex >= 0 && projectsIndex < parts.length - 1) {
|
||||||
|
const projectRelative = parts.slice(projectsIndex + 1);
|
||||||
|
if (projectRelative.length <= 4) {
|
||||||
|
return projectRelative.join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tail = parts.slice(-3);
|
||||||
|
return tail[0] === 'projects' ? parts.slice(-2).join('/') : tail.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
function addContextToSuccessResultPreview(
|
function addContextToSuccessResultPreview(
|
||||||
preview: ValuePreview,
|
preview: ValuePreview,
|
||||||
context: string | null,
|
context: string | null,
|
||||||
|
|
@ -580,15 +611,16 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
|
||||||
stringField(input, 'filePath') ??
|
stringField(input, 'filePath') ??
|
||||||
stringField(input, 'path') ??
|
stringField(input, 'path') ??
|
||||||
stringField(input, 'cwd');
|
stringField(input, 'cwd');
|
||||||
|
const compactPath = path ? shortenPathForPreview(path) : null;
|
||||||
if (toolContext.canonicalName === 'grep') {
|
if (toolContext.canonicalName === 'grep') {
|
||||||
const query = stringField(input, 'query') ?? stringField(input, 'pattern');
|
const query = stringField(input, 'query') ?? stringField(input, 'pattern');
|
||||||
if (query && path) return `${query} in ${path}`;
|
if (query && compactPath) return `${query} in ${compactPath}`;
|
||||||
return query ?? path;
|
return query ?? compactPath;
|
||||||
}
|
}
|
||||||
if (toolContext.canonicalName === 'glob') {
|
if (toolContext.canonicalName === 'glob') {
|
||||||
const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob');
|
const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob');
|
||||||
if (pattern && path) return `${pattern} in ${path}`;
|
if (pattern && compactPath) return `${pattern} in ${compactPath}`;
|
||||||
return pattern ?? path;
|
return pattern ?? compactPath;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
toolContext.canonicalName === 'read' ||
|
toolContext.canonicalName === 'read' ||
|
||||||
|
|
@ -596,7 +628,7 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
|
||||||
toolContext.canonicalName === 'edit' ||
|
toolContext.canonicalName === 'edit' ||
|
||||||
toolContext.canonicalName === 'ls'
|
toolContext.canonicalName === 'ls'
|
||||||
) {
|
) {
|
||||||
return path;
|
return compactPath;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -733,7 +765,7 @@ function formatTaskCommentPayload(
|
||||||
if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`;
|
if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`;
|
||||||
if (author) return `Comment by ${author}: ${commentText}`;
|
if (author) return `Comment by ${author}: ${commentText}`;
|
||||||
if (taskRef) return `Comment on ${taskRef}: ${commentText}`;
|
if (taskRef) return `Comment on ${taskRef}: ${commentText}`;
|
||||||
return `Comment: ${commentText}`;
|
return commentText;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null {
|
function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null {
|
||||||
|
|
@ -1485,6 +1517,34 @@ function formatPlainToolResultStatus(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taggedSection(value: string, tag: string): string | null {
|
||||||
|
const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(value);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTaggedFileLineNumbers(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\(End of file - total \d+ lines?\)/gi, ' ')
|
||||||
|
.replace(/\(\d+ entries\)/gi, ' ')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/^\s*\d+:\s*/, '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaggedFileToolResult(value: string): string | null {
|
||||||
|
const content = taggedSection(value, 'content') ?? taggedSection(value, 'entries');
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = taggedSection(value, 'type')?.toLowerCase();
|
||||||
|
const body = compactWhitespace(stripTaggedFileLineNumbers(content));
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return type === 'directory' ? `directory ${body}` : body;
|
||||||
|
}
|
||||||
|
|
||||||
function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null {
|
function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null {
|
||||||
const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
|
const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
|
||||||
if (!compact) {
|
if (!compact) {
|
||||||
|
|
@ -1649,6 +1709,10 @@ function previewUnknownValue(
|
||||||
if (plainStatus) {
|
if (plainStatus) {
|
||||||
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
|
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
|
||||||
}
|
}
|
||||||
|
const taggedFileResult = formatTaggedFileToolResult(value);
|
||||||
|
if (taggedFileResult) {
|
||||||
|
return truncatePreview(taggedFileResult, limit);
|
||||||
|
}
|
||||||
const parsed = parseJsonLikeString(value);
|
const parsed = parseJsonLikeString(value);
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
|
return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
|
||||||
|
|
@ -1749,6 +1813,15 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
|
||||||
: 'Read cross-team outbox';
|
: 'Read cross-team outbox';
|
||||||
return truncatePreview(text, limit);
|
return truncatePreview(text, limit);
|
||||||
}
|
}
|
||||||
|
const fileToolContext = formatFileToolResultContext({
|
||||||
|
id: '',
|
||||||
|
name: toolName,
|
||||||
|
canonicalName: canonical,
|
||||||
|
input: value,
|
||||||
|
});
|
||||||
|
if (fileToolContext) {
|
||||||
|
return truncatePreview(fileToolContext, limit);
|
||||||
|
}
|
||||||
const payload = recordFromUnknown(value);
|
const payload = recordFromUnknown(value);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const runtimeFormatted = formatRuntimePayload(payload, canonical, payload);
|
const runtimeFormatted = formatRuntimePayload(payload, canonical, payload);
|
||||||
|
|
|
||||||
|
|
@ -16230,6 +16230,7 @@ export class TeamProvisioningService {
|
||||||
run.child.stderr?.removeAllListeners('data');
|
run.child.stderr?.removeAllListeners('data');
|
||||||
run.child.removeAllListeners('error');
|
run.child.removeAllListeners('error');
|
||||||
run.child.removeAllListeners('exit');
|
run.child.removeAllListeners('exit');
|
||||||
|
run.child.removeAllListeners('close');
|
||||||
killTeamProcess(run.child);
|
killTeamProcess(run.child);
|
||||||
run.child = null;
|
run.child = null;
|
||||||
}
|
}
|
||||||
|
|
@ -16469,7 +16470,7 @@ export class TeamProvisioningService {
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
child.once('close', (code) => {
|
||||||
void this.handleProcessExit(run, code);
|
void this.handleProcessExit(run, code);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -17083,7 +17084,7 @@ export class TeamProvisioningService {
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
child.once('close', (code) => {
|
||||||
void this.handleProcessExit(run, code);
|
void this.handleProcessExit(run, code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -18381,7 +18382,7 @@ export class TeamProvisioningService {
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
child.once('close', (code) => {
|
||||||
void this.handleProcessExit(run, code);
|
void this.handleProcessExit(run, code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -908,7 +908,7 @@ const InstalledBanner = ({
|
||||||
color: '#fbbf24',
|
color: '#fbbf24',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'}
|
{codexLoginAuthUrl ? 'Open login' : 'Generate link'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1153,16 +1153,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const handleCodexDashboardLogin = useCallback(() => {
|
const handleCodexDashboardLogin = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const success = await codexAccount.startChatgptLogin();
|
await codexAccount.startChatgptLogin();
|
||||||
if (success) {
|
|
||||||
await refreshCliStatusForCurrentMode({
|
|
||||||
multimodelEnabled,
|
|
||||||
bootstrapCliStatus,
|
|
||||||
fetchCliStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
}, [codexAccount]);
|
||||||
|
|
||||||
const recheckAuthState = useCallback(() => {
|
const recheckAuthState = useCallback(() => {
|
||||||
setIsVerifyingAuth(true);
|
setIsVerifyingAuth(true);
|
||||||
|
|
|
||||||
|
|
@ -1439,7 +1439,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
onClick={() => void handleCodexStartLogin()}
|
onClick={() => void handleCodexStartLogin()}
|
||||||
>
|
>
|
||||||
<Link2 className="mr-1 size-3.5" />
|
<Link2 className="mr-1 size-3.5" />
|
||||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
|
|
||||||
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
|
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
|
||||||
|
|
@ -78,13 +79,19 @@ export const CodexReconnectPrompt = ({
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="min-w-0 flex-1 text-[11px] text-amber-100/90">
|
<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
|
Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
|
||||||
finish login in the browser and retry this dialog.
|
then finish login in the browser and retry this dialog.
|
||||||
</p>
|
</p>
|
||||||
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
|
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onReconnect}
|
onClick={() => {
|
||||||
|
if (authUrl) {
|
||||||
|
void api.openExternal(authUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onReconnect();
|
||||||
|
}}
|
||||||
disabled={reconnectBusy}
|
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"
|
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={{
|
style={{
|
||||||
|
|
@ -93,7 +100,7 @@ export const CodexReconnectPrompt = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogIn className="size-3" />
|
<LogIn className="size-3" />
|
||||||
{reconnectBusy ? 'Opening...' : 'Reconnect ChatGPT'}
|
{reconnectBusy ? 'Generating...' : authUrl ? 'Open login' : 'Generate link'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -713,16 +713,9 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
const handleCodexReconnect = useCallback(() => {
|
const handleCodexReconnect = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const success = await codexAccount.startChatgptLogin();
|
await codexAccount.startChatgptLogin();
|
||||||
if (success) {
|
|
||||||
await refreshCliStatusForCurrentMode({
|
|
||||||
multimodelEnabled,
|
|
||||||
bootstrapCliStatus,
|
|
||||||
fetchCliStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
}, [codexAccount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
|
|
|
||||||
|
|
@ -590,16 +590,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
const handleCodexReconnect = React.useCallback(() => {
|
const handleCodexReconnect = React.useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const success = await codexAccount.startChatgptLogin();
|
await codexAccount.startChatgptLogin();
|
||||||
if (success) {
|
|
||||||
await refreshCliStatusForCurrentMode({
|
|
||||||
multimodelEnabled,
|
|
||||||
bootstrapCliStatus,
|
|
||||||
fetchCliStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
}, [codexAccount]);
|
||||||
|
|
||||||
// Schedule store actions
|
// Schedule store actions
|
||||||
const createSchedule = useStore((s) => s.createSchedule);
|
const createSchedule = useStore((s) => s.createSchedule);
|
||||||
|
|
|
||||||
|
|
@ -12356,6 +12356,56 @@ describe('TeamProvisioningService', () => {
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits for child close before handling launch process exit so stream-json can drain', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'launch-close-drains-stdout-team';
|
||||||
|
const leadSessionId = 'lead-session-close-drain';
|
||||||
|
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||||
|
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
const child = createRunningChild();
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||||
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||||
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
} as any);
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { ANTHROPIC_API_KEY: 'test' },
|
||||||
|
authSource: 'anthropic_api_key',
|
||||||
|
}));
|
||||||
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
source: 'members-meta',
|
||||||
|
warning: undefined,
|
||||||
|
}));
|
||||||
|
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||||
|
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||||
|
);
|
||||||
|
const handleProcessExit = vi
|
||||||
|
.spyOn(svc as any, 'handleProcessExit')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {});
|
||||||
|
|
||||||
|
child.emit('exit', 0);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(handleProcessExit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
child.emit('close', 0);
|
||||||
|
await vi.waitFor(() => expect(handleProcessExit).toHaveBeenCalledTimes(1));
|
||||||
|
expect(handleProcessExit.mock.calls[0]?.[1]).toBe(0);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,78 @@ describe('useGraphMemberLogPreviews', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores stale responses when the same member receives a newer lane request', async () => {
|
||||||
|
const oldLaneLoad = createDeferred<MemberLogPreviewResponse>();
|
||||||
|
const newLaneLoad = createDeferred<MemberLogPreviewResponse>();
|
||||||
|
apiMock.memberLogStream.getMemberLogPreviews
|
||||||
|
.mockReturnValueOnce(oldLaneLoad.promise)
|
||||||
|
.mockReturnValueOnce(newLaneLoad.promise);
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||||
|
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||||
|
states.push(state);
|
||||||
|
});
|
||||||
|
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||||
|
states.at(-1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<HookProbe
|
||||||
|
teamName="alpha-team"
|
||||||
|
memberNames={['alice']}
|
||||||
|
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
|
||||||
|
onState={onState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<HookProbe
|
||||||
|
teamName="alpha-team"
|
||||||
|
memberNames={['alice']}
|
||||||
|
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
|
||||||
|
onState={onState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
newLaneLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
|
||||||
|
'alice:2026-04-03T00:01:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
oldLaneLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
|
||||||
|
'alice:2026-04-03T00:01:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reloads visible members on log-source events with force refresh', async () => {
|
it('reloads visible members on log-source events with force refresh', async () => {
|
||||||
let teamChangeListener:
|
let teamChangeListener:
|
||||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue