mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
feat: 添加 UI 组件增强与测试覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
EFFORT_LOW,
|
EFFORT_LOW,
|
||||||
EFFORT_MAX,
|
EFFORT_MAX,
|
||||||
EFFORT_MEDIUM,
|
EFFORT_MEDIUM,
|
||||||
|
EFFORT_XHIGH,
|
||||||
} from '../constants/figures.js'
|
} from '../constants/figures.js'
|
||||||
import {
|
import {
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
@@ -32,6 +33,8 @@ export function effortLevelToSymbol(level: EffortLevel): string {
|
|||||||
return EFFORT_MEDIUM
|
return EFFORT_MEDIUM
|
||||||
case 'high':
|
case 'high':
|
||||||
return EFFORT_HIGH
|
return EFFORT_HIGH
|
||||||
|
case 'xhigh':
|
||||||
|
return EFFORT_XHIGH
|
||||||
case 'max':
|
case 'max':
|
||||||
return EFFORT_MAX
|
return EFFORT_MAX
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { renderToString } from '../../../utils/staticRender.js';
|
||||||
|
import type { Message } from '../../../types/message.js';
|
||||||
|
|
||||||
|
let transcriptShareDismissed = false;
|
||||||
|
let productFeedbackAllowed = true;
|
||||||
|
const mockSubmitTranscriptShare = mock(async () => ({ success: true }));
|
||||||
|
|
||||||
|
mock.module('../../../utils/config.js', () => ({
|
||||||
|
getGlobalConfig: () => ({ transcriptShareDismissed }),
|
||||||
|
saveGlobalConfig: (
|
||||||
|
updater: (current: { transcriptShareDismissed?: boolean }) => {
|
||||||
|
transcriptShareDismissed?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const next = updater({ transcriptShareDismissed });
|
||||||
|
transcriptShareDismissed = next.transcriptShareDismissed ?? false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module('../../../services/policyLimits/index.js', () => ({
|
||||||
|
isPolicyAllowed: () => productFeedbackAllowed,
|
||||||
|
}));
|
||||||
|
mock.module('../submitTranscriptShare.js', () => ({
|
||||||
|
submitTranscriptShare: mockSubmitTranscriptShare,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useFrustrationDetection } = await import('../useFrustrationDetection.js');
|
||||||
|
|
||||||
|
type DetectionResult = ReturnType<typeof useFrustrationDetection>;
|
||||||
|
|
||||||
|
function apiError(uuid: string): Message {
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: uuid as any,
|
||||||
|
isApiErrorMessage: true,
|
||||||
|
message: { role: 'assistant', content: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDetection(props: {
|
||||||
|
messages: Message[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
hasActivePrompt?: boolean;
|
||||||
|
otherSurveyOpen?: boolean;
|
||||||
|
}): Promise<DetectionResult> {
|
||||||
|
let result: DetectionResult | null = null;
|
||||||
|
function Probe(): React.ReactNode {
|
||||||
|
result = useFrustrationDetection(
|
||||||
|
props.messages,
|
||||||
|
props.isLoading ?? false,
|
||||||
|
props.hasActivePrompt ?? false,
|
||||||
|
props.otherSurveyOpen ?? false,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderToString(<Probe />);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('useFrustrationDetection did not render');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
transcriptShareDismissed = false;
|
||||||
|
productFeedbackAllowed = true;
|
||||||
|
mockSubmitTranscriptShare.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useFrustrationDetection', () => {
|
||||||
|
test('stays closed without frustration signals', async () => {
|
||||||
|
const result = await renderDetection({ messages: [] });
|
||||||
|
|
||||||
|
expect(result.state).toBe('closed');
|
||||||
|
expect(typeof result.handleTranscriptSelect).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens a transcript prompt for repeated API errors', async () => {
|
||||||
|
const result = await renderDetection({
|
||||||
|
messages: [apiError('a'), apiError('b')],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.state).toBe('transcript_prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not prompt while loading, prompting, blocked by another survey, dismissed, or policy-denied', async () => {
|
||||||
|
const messages = [apiError('a'), apiError('b')];
|
||||||
|
|
||||||
|
expect((await renderDetection({ messages, isLoading: true })).state).toBe('closed');
|
||||||
|
expect((await renderDetection({ messages, hasActivePrompt: true })).state).toBe('closed');
|
||||||
|
expect((await renderDetection({ messages, otherSurveyOpen: true })).state).toBe('closed');
|
||||||
|
|
||||||
|
transcriptShareDismissed = true;
|
||||||
|
expect((await renderDetection({ messages })).state).toBe('closed');
|
||||||
|
|
||||||
|
transcriptShareDismissed = false;
|
||||||
|
productFeedbackAllowed = false;
|
||||||
|
expect((await renderDetection({ messages })).state).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submits transcript share when the user accepts', async () => {
|
||||||
|
const result = await renderDetection({
|
||||||
|
messages: [apiError('a'), apiError('b')],
|
||||||
|
});
|
||||||
|
|
||||||
|
result.handleTranscriptSelect('yes');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(mockSubmitTranscriptShare).toHaveBeenCalledWith(
|
||||||
|
[apiError('a'), apiError('b')],
|
||||||
|
'frustration',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,59 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import { useState } from 'react'
|
||||||
export function useFrustrationDetection(
|
import type { Message } from '../../types/message.js'
|
||||||
_messages: unknown[],
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||||
_isLoading: boolean,
|
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
|
||||||
_hasActivePrompt: boolean,
|
import { submitTranscriptShare } from './submitTranscriptShare.js'
|
||||||
_otherSurveyOpen: boolean,
|
|
||||||
): { state: 'closed' | 'open'; handleTranscriptSelect: () => void } {
|
type FrustrationState = 'closed' | 'transcript_prompt' | 'submitted'
|
||||||
return { state: 'closed', handleTranscriptSelect: () => {} };
|
|
||||||
|
export type FrustrationDetectionResult = {
|
||||||
|
state: FrustrationState
|
||||||
|
handleTranscriptSelect: (choice: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectFrustration(messages: Message[]): boolean {
|
||||||
|
const apiErrors = messages.filter(m => (m as any).isApiErrorMessage)
|
||||||
|
return apiErrors.length >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFrustrationDetection(
|
||||||
|
messages: Message[],
|
||||||
|
isLoading: boolean,
|
||||||
|
hasActivePrompt: boolean,
|
||||||
|
otherSurveyOpen: boolean,
|
||||||
|
): FrustrationDetectionResult {
|
||||||
|
const [state, setState] = useState<FrustrationState>('closed')
|
||||||
|
|
||||||
|
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
|
||||||
|
if (config.transcriptShareDismissed) {
|
||||||
|
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPolicyAllowed('product_feedback' as any)) {
|
||||||
|
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || hasActivePrompt || otherSurveyOpen) {
|
||||||
|
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const frustrated = detectFrustration(messages)
|
||||||
|
|
||||||
|
const effectiveState =
|
||||||
|
frustrated && state === 'closed' ? 'transcript_prompt' : state
|
||||||
|
|
||||||
|
function handleTranscriptSelect(choice: string) {
|
||||||
|
if (choice === 'yes') {
|
||||||
|
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
|
||||||
|
setState('submitted')
|
||||||
|
} else {
|
||||||
|
saveGlobalConfig((current: any) => ({
|
||||||
|
...current,
|
||||||
|
transcriptShareDismissed: true,
|
||||||
|
}))
|
||||||
|
setState('closed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: effectiveState, handleTranscriptSelect }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export async function showInvalidConfigDialog({
|
|||||||
theme: SAFE_ERROR_THEME_NAME,
|
theme: SAFE_ERROR_THEME_NAME,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: render must be awaited inside executor
|
||||||
await new Promise<void>(async resolve => {
|
await new Promise<void>(async resolve => {
|
||||||
const { unmount } = await render(
|
const { unmount } = await render(
|
||||||
<AppStateProvider>
|
<AppStateProvider>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,79 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import React from 'react'
|
||||||
import type React from 'react';
|
import { Dialog, Text } from '@anthropic/ink'
|
||||||
import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js';
|
import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'
|
||||||
|
import { Select } from '../CustomSelect/index.js'
|
||||||
|
|
||||||
export {};
|
interface SnapshotUpdateDialogProps {
|
||||||
export const SnapshotUpdateDialog: React.FC<{
|
agentType: string
|
||||||
agentType: string;
|
scope: AgentMemoryScope
|
||||||
scope: AgentMemoryScope;
|
snapshotTimestamp: string
|
||||||
snapshotTimestamp: string;
|
onComplete: (choice: 'merge' | 'keep' | 'replace') => void
|
||||||
onComplete: (choice: 'merge' | 'keep' | 'replace') => void;
|
onCancel: () => void
|
||||||
onCancel: () => void;
|
}
|
||||||
}> = (() => null);
|
|
||||||
export const buildMergePrompt: (agentType: string, scope: AgentMemoryScope) => string = (() => '');
|
// Ink uses React.createElement instead of JSX here so the real implementation
|
||||||
|
// can live in a .ts file (bun's `.js` import resolver picks up .ts before
|
||||||
|
// .tsx in this repo's layout, so co-locating both extensions would shadow
|
||||||
|
// this module with an empty stub).
|
||||||
|
export function SnapshotUpdateDialog({
|
||||||
|
agentType,
|
||||||
|
scope,
|
||||||
|
snapshotTimestamp,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
}: SnapshotUpdateDialogProps): React.ReactElement {
|
||||||
|
const children = [
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ dimColor: true, key: 'timestamp' },
|
||||||
|
`Snapshot timestamp: ${snapshotTimestamp}`,
|
||||||
|
),
|
||||||
|
React.createElement(Select, {
|
||||||
|
key: 'select',
|
||||||
|
defaultFocusValue: 'merge',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'Merge snapshot into current memory',
|
||||||
|
value: 'merge',
|
||||||
|
description:
|
||||||
|
'Keep current memory and ask Claude to merge in the snapshot changes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Keep current memory',
|
||||||
|
value: 'keep',
|
||||||
|
description:
|
||||||
|
'Ignore this snapshot update and continue with current memory.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Replace with snapshot',
|
||||||
|
value: 'replace',
|
||||||
|
description:
|
||||||
|
'Overwrite current memory files with the snapshot contents.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: onComplete as (value: unknown) => void,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return React.createElement(Dialog, {
|
||||||
|
title: 'Agent memory snapshot update',
|
||||||
|
subtitle: `A newer ${scope} memory snapshot is available for ${agentType}.`,
|
||||||
|
onCancel,
|
||||||
|
color: 'warning' as const,
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMergePrompt(
|
||||||
|
agentType: string,
|
||||||
|
scope: AgentMemoryScope,
|
||||||
|
): string {
|
||||||
|
return `A newer ${scope} persistent memory snapshot is available for the "${agentType}" agent.
|
||||||
|
|
||||||
|
Please merge the snapshot update into the current ${scope} agent memory before continuing:
|
||||||
|
- Preserve useful current memory entries.
|
||||||
|
- Incorporate newer or more accurate information from the snapshot.
|
||||||
|
- Resolve duplicates or conflicts in favor of the most current, specific information.
|
||||||
|
- Keep the memory concise and relevant to future runs of this agent.
|
||||||
|
|
||||||
|
After merging, continue with the user's request.`
|
||||||
|
}
|
||||||
|
|||||||
115
src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx
Normal file
115
src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { launchSnapshotUpdateDialog } from '../../../dialogLaunchers.js';
|
||||||
|
import { buildMergePrompt, SnapshotUpdateDialog } from '../SnapshotUpdateDialog.js';
|
||||||
|
import { Select } from '../../CustomSelect/index.js';
|
||||||
|
|
||||||
|
function getSnapshotDialogFromRenderedTree(rendered: React.ReactElement) {
|
||||||
|
const appStateProvider = rendered as React.ReactElement<{
|
||||||
|
children: React.ReactElement;
|
||||||
|
}>;
|
||||||
|
const keybindingSetup = appStateProvider.props.children as React.ReactElement<{
|
||||||
|
children: React.ReactElement;
|
||||||
|
}>;
|
||||||
|
return keybindingSetup.props.children as React.ReactElement<{
|
||||||
|
agentType: string;
|
||||||
|
scope: string;
|
||||||
|
snapshotTimestamp: string;
|
||||||
|
onComplete: (choice: 'merge' | 'keep' | 'replace') => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRender(getRendered: () => React.ReactElement | null): Promise<React.ReactElement> {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const rendered = getRendered();
|
||||||
|
if (rendered) return rendered;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
throw new Error('Snapshot update dialog was not rendered');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SnapshotUpdateDialog', () => {
|
||||||
|
test('launchSnapshotUpdateDialog wires props and keep-on-cancel semantics through showSetupDialog', async () => {
|
||||||
|
let rendered: React.ReactElement | null = null;
|
||||||
|
const root = {
|
||||||
|
render(node: React.ReactElement) {
|
||||||
|
rendered = node;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const resultPromise = launchSnapshotUpdateDialog(root, {
|
||||||
|
agentType: 'researcher',
|
||||||
|
scope: 'project',
|
||||||
|
snapshotTimestamp: '2026-04-15T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogElement = getSnapshotDialogFromRenderedTree(await waitForRender(() => rendered));
|
||||||
|
|
||||||
|
expect(dialogElement.type).toBe(SnapshotUpdateDialog);
|
||||||
|
expect(dialogElement.props.agentType).toBe('researcher');
|
||||||
|
expect(dialogElement.props.scope).toBe('project');
|
||||||
|
expect(dialogElement.props.snapshotTimestamp).toBe('2026-04-15T12:00:00.000Z');
|
||||||
|
|
||||||
|
dialogElement.props.onCancel();
|
||||||
|
await expect(resultPromise).resolves.toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launchSnapshotUpdateDialog forwards explicit completion choices', async () => {
|
||||||
|
let rendered: React.ReactElement | null = null;
|
||||||
|
const root = {
|
||||||
|
render(node: React.ReactElement) {
|
||||||
|
rendered = node;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const resultPromise = launchSnapshotUpdateDialog(root, {
|
||||||
|
agentType: 'researcher',
|
||||||
|
scope: 'user',
|
||||||
|
snapshotTimestamp: '2026-04-15T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogElement = getSnapshotDialogFromRenderedTree(await waitForRender(() => rendered));
|
||||||
|
dialogElement.props.onComplete('replace');
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBe('replace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMergePrompt is non-empty and varies with both agentType and scope', () => {
|
||||||
|
const projectPrompt = buildMergePrompt('researcher', 'project');
|
||||||
|
const userPrompt = buildMergePrompt('researcher', 'user');
|
||||||
|
const plannerPrompt = buildMergePrompt('planner', 'project');
|
||||||
|
|
||||||
|
expect(projectPrompt.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(projectPrompt).toContain('researcher');
|
||||||
|
expect(projectPrompt).toContain('project');
|
||||||
|
expect(projectPrompt.toLowerCase()).toContain('snapshot');
|
||||||
|
expect(projectPrompt.toLowerCase()).toContain('merge');
|
||||||
|
expect(projectPrompt).not.toBe(userPrompt);
|
||||||
|
expect(projectPrompt).not.toBe(plannerPrompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders snapshot metadata and choice options from its public props', () => {
|
||||||
|
const element = SnapshotUpdateDialog({
|
||||||
|
agentType: 'researcher',
|
||||||
|
scope: 'project',
|
||||||
|
snapshotTimestamp: '2026-04-15T12:00:00.000Z',
|
||||||
|
onComplete: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
} as any) as React.ReactElement<{ title: string; subtitle: string; children: React.ReactNode[] }>;
|
||||||
|
|
||||||
|
expect(element.props.title).toBe('Agent memory snapshot update');
|
||||||
|
expect(element.props.subtitle).toContain('researcher');
|
||||||
|
expect(element.props.subtitle).toContain('project');
|
||||||
|
|
||||||
|
const [timestamp, select] = element.props.children as Array<React.ReactElement<Record<string, any>>>;
|
||||||
|
expect(timestamp.props.children).toContain('2026-04-15T12:00:00.000Z');
|
||||||
|
expect(select.type).toBe(Select);
|
||||||
|
expect(select.props.options.map((option: { value: string }) => option.value)).toEqual(['merge', 'keep', 'replace']);
|
||||||
|
expect(select.props.options.map((option: { label: string }) => option.label)).toEqual([
|
||||||
|
'Merge snapshot into current memory',
|
||||||
|
'Keep current memory',
|
||||||
|
'Replace with snapshot',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/components/messages/SnipBoundaryMessage.tsx
Normal file
23
src/components/messages/SnipBoundaryMessage.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* SnipBoundaryMessage — visual separator showing where conversation was snipped.
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Text } from '@anthropic/ink';
|
||||||
|
import type { Message } from '../../types/message.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: Message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SnipBoundaryMessage({ message }: Props): React.ReactNode {
|
||||||
|
const content =
|
||||||
|
typeof (message as Record<string, unknown>).content === 'string'
|
||||||
|
? ((message as Record<string, unknown>).content as string)
|
||||||
|
: '[snip] Conversation history before this point has been snipped.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box marginTop={1} marginBottom={1}>
|
||||||
|
<Text dimColor>── {content} ──</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/messages/UserCrossSessionMessage.tsx
Normal file
31
src/components/messages/UserCrossSessionMessage.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* UserCrossSessionMessage — render a message received from another Claude session
|
||||||
|
* via UDS_INBOX (SendMessage tool).
|
||||||
|
*/
|
||||||
|
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Text } from '@anthropic/ink';
|
||||||
|
import { extractTag } from '../../utils/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
addMargin: boolean;
|
||||||
|
param: TextBlockParam;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserCrossSessionMessage({ param, addMargin }: Props): React.ReactNode {
|
||||||
|
const text = param.text;
|
||||||
|
const extracted = extractTag(text, 'cross-session-message');
|
||||||
|
if (!extracted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromMatch = text.match(/from="([^"]*)"/);
|
||||||
|
const from = fromMatch?.[1] ?? 'another session';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginTop={addMargin ? 1 : 0}>
|
||||||
|
<Text dimColor>[{from}] </Text>
|
||||||
|
<Text>{extracted}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/messages/UserForkBoilerplateMessage.tsx
Normal file
30
src/components/messages/UserForkBoilerplateMessage.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* UserForkBoilerplateMessage — render the fork/subagent boilerplate directive.
|
||||||
|
*/
|
||||||
|
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Text } from '@anthropic/ink';
|
||||||
|
import { extractTag } from '../../utils/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
addMargin: boolean;
|
||||||
|
param: TextBlockParam;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserForkBoilerplateMessage({ param, addMargin }: Props): React.ReactNode {
|
||||||
|
const text = param.text;
|
||||||
|
const extracted = extractTag(text, 'fork-boilerplate');
|
||||||
|
if (!extracted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLine = extracted.trim().split('\n')[0] ?? '';
|
||||||
|
const preview = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginTop={addMargin ? 1 : 0}>
|
||||||
|
<Text dimColor>[fork] </Text>
|
||||||
|
<Text>{preview}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/messages/UserGitHubWebhookMessage.tsx
Normal file
36
src/components/messages/UserGitHubWebhookMessage.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* UserGitHubWebhookMessage — render inbound GitHub webhook activity.
|
||||||
|
*/
|
||||||
|
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Text } from '@anthropic/ink';
|
||||||
|
import { extractTag } from '../../utils/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
addMargin: boolean;
|
||||||
|
param: TextBlockParam;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserGitHubWebhookMessage({ param, addMargin }: Props): React.ReactNode {
|
||||||
|
const text = param.text;
|
||||||
|
const extracted = extractTag(text, 'github-webhook-activity');
|
||||||
|
if (!extracted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventMatch = extracted.match(/event[_-]?type[":\s]+["']?(\w+)/);
|
||||||
|
const repoMatch = extracted.match(/repo(?:sitory)?[":\s]+["']?([^"'\s,}]+)/);
|
||||||
|
const event = eventMatch?.[1] ?? 'activity';
|
||||||
|
const repo = repoMatch?.[1] ?? '';
|
||||||
|
const repoSuffix = repo ? ` in ${repo}` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginTop={addMargin ? 1 : 0}>
|
||||||
|
<Text dimColor>[GitHub] </Text>
|
||||||
|
<Text>
|
||||||
|
{event}
|
||||||
|
{repoSuffix}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ export function OutputLine({
|
|||||||
export function stripUnderlineAnsi(content: string): string {
|
export function stripUnderlineAnsi(content: string): string {
|
||||||
return content.replace(
|
return content.replace(
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape code regex
|
||||||
/\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g,
|
/\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g,
|
||||||
'',
|
'',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,309 +1,262 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto';
|
||||||
import figures from 'figures'
|
import figures from 'figures';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useInterval } from 'usehooks-ts'
|
import { useInterval } from 'usehooks-ts';
|
||||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation
|
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation
|
||||||
import { Box, Text, useInput, stringWidth } from '@anthropic/ink'
|
import { Box, Text, useInput, stringWidth } from '@anthropic/ink';
|
||||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||||
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
|
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
|
||||||
import {
|
import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js';
|
||||||
type AppState,
|
import { getEmptyToolPermissionContext } from '../../Tool.js';
|
||||||
useAppState,
|
import { AGENT_COLOR_TO_THEME_COLOR } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
|
||||||
useSetAppState,
|
import { logForDebugging } from '../../utils/debug.js';
|
||||||
} from '../../state/AppState.js'
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
import { truncateToWidth } from '../../utils/format.js';
|
||||||
import { AGENT_COLOR_TO_THEME_COLOR } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
|
import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js';
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
|
||||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
|
||||||
import { truncateToWidth } from '../../utils/format.js'
|
|
||||||
import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'
|
|
||||||
import {
|
import {
|
||||||
getModeColor,
|
getModeColor,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
permissionModeFromString,
|
permissionModeFromString,
|
||||||
permissionModeSymbol,
|
permissionModeSymbol,
|
||||||
} from '../../utils/permissions/PermissionMode.js'
|
} from '../../utils/permissions/PermissionMode.js';
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
import { jsonStringify } from '../../utils/slowOperations.js';
|
||||||
import {
|
import { IT2_COMMAND, isInsideTmuxSync } from '../../utils/swarm/backends/detection.js';
|
||||||
IT2_COMMAND,
|
import { ensureBackendsRegistered, getBackendByType, getCachedBackend } from '../../utils/swarm/backends/registry.js';
|
||||||
isInsideTmuxSync,
|
import { isPaneBackend, type PaneBackendType } from '../../utils/swarm/backends/types.js';
|
||||||
} from '../../utils/swarm/backends/detection.js'
|
import { getSwarmSocketName, TMUX_COMMAND } from '../../utils/swarm/constants.js';
|
||||||
import {
|
|
||||||
ensureBackendsRegistered,
|
|
||||||
getBackendByType,
|
|
||||||
getCachedBackend,
|
|
||||||
} from '../../utils/swarm/backends/registry.js'
|
|
||||||
import type { PaneBackendType } from '../../utils/swarm/backends/types.js'
|
|
||||||
import {
|
|
||||||
getSwarmSocketName,
|
|
||||||
TMUX_COMMAND,
|
|
||||||
} from '../../utils/swarm/constants.js'
|
|
||||||
import {
|
import {
|
||||||
addHiddenPaneId,
|
addHiddenPaneId,
|
||||||
removeHiddenPaneId,
|
removeHiddenPaneId,
|
||||||
removeMemberFromTeam,
|
removeMemberFromTeam,
|
||||||
setMemberMode,
|
setMemberMode,
|
||||||
setMultipleMemberModes,
|
setMultipleMemberModes,
|
||||||
} from '../../utils/swarm/teamHelpers.js'
|
} from '../../utils/swarm/teamHelpers.js';
|
||||||
import {
|
import { listTasks, type Task, unassignTeammateTasks } from '../../utils/tasks.js';
|
||||||
listTasks,
|
import { getTeammateStatuses, type TeammateStatus, type TeamSummary } from '../../utils/teamDiscovery.js';
|
||||||
type Task,
|
|
||||||
unassignTeammateTasks,
|
|
||||||
} from '../../utils/tasks.js'
|
|
||||||
import {
|
|
||||||
getTeammateStatuses,
|
|
||||||
type TeammateStatus,
|
|
||||||
type TeamSummary,
|
|
||||||
} from '../../utils/teamDiscovery.js'
|
|
||||||
import {
|
import {
|
||||||
createModeSetRequestMessage,
|
createModeSetRequestMessage,
|
||||||
sendShutdownRequestToMailbox,
|
sendShutdownRequestToMailbox,
|
||||||
writeToMailbox,
|
writeToMailbox,
|
||||||
} from '../../utils/teammateMailbox.js'
|
} from '../../utils/teammateMailbox.js';
|
||||||
import { Dialog } from '@anthropic/ink'
|
import { Dialog } from '@anthropic/ink';
|
||||||
import ThemedText from '../design-system/ThemedText.js'
|
import ThemedText from '../design-system/ThemedText.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialTeams?: TeamSummary[]
|
initialTeams?: TeamSummary[];
|
||||||
onDone: () => void
|
onDone: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type DialogLevel =
|
type DialogLevel =
|
||||||
| { type: 'teammateList'; teamName: string }
|
| { type: 'teammateList'; teamName: string }
|
||||||
| { type: 'teammateDetail'; teamName: string; memberName: string }
|
| { type: 'teammateDetail'; teamName: string; memberName: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog for viewing teammates in the current team
|
* Dialog for viewing teammates in the current team
|
||||||
*/
|
*/
|
||||||
export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
||||||
// Register as overlay so CancelRequestHandler doesn't intercept escape
|
// Register as overlay so CancelRequestHandler doesn't intercept escape
|
||||||
useRegisterOverlay('teams-dialog')
|
useRegisterOverlay('teams-dialog');
|
||||||
|
|
||||||
// initialTeams is derived from teamContext in PromptInput (no filesystem I/O)
|
// initialTeams is derived from teamContext in PromptInput (no filesystem I/O)
|
||||||
const setAppState = useSetAppState()
|
const setAppState = useSetAppState();
|
||||||
|
|
||||||
// Initialize dialogLevel with first team name if available
|
// Initialize dialogLevel with first team name if available
|
||||||
const firstTeamName = initialTeams?.[0]?.name ?? ''
|
const firstTeamName = initialTeams?.[0]?.name ?? '';
|
||||||
const [dialogLevel, setDialogLevel] = useState<DialogLevel>({
|
const [dialogLevel, setDialogLevel] = useState<DialogLevel>({
|
||||||
type: 'teammateList',
|
type: 'teammateList',
|
||||||
teamName: firstTeamName,
|
teamName: firstTeamName,
|
||||||
})
|
});
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
// initialTeams is now always provided from PromptInput (derived from teamContext)
|
// initialTeams is now always provided from PromptInput (derived from teamContext)
|
||||||
// No filesystem I/O needed here
|
// No filesystem I/O needed here
|
||||||
|
|
||||||
const teammateStatuses = useMemo(() => {
|
const teammateStatuses = useMemo(() => {
|
||||||
return getTeammateStatuses(dialogLevel.teamName)
|
return getTeammateStatuses(dialogLevel.teamName);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||||
}, [dialogLevel.teamName, refreshKey])
|
}, [dialogLevel.teamName, refreshKey]);
|
||||||
|
|
||||||
// Periodically refresh to pick up mode changes from teammates
|
// Periodically refresh to pick up mode changes from teammates
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
setRefreshKey(k => k + 1)
|
setRefreshKey(k => k + 1);
|
||||||
}, 1000)
|
}, 1000);
|
||||||
|
|
||||||
const currentTeammate = useMemo(() => {
|
const currentTeammate = useMemo(() => {
|
||||||
if (dialogLevel.type !== 'teammateDetail') return null
|
if (dialogLevel.type !== 'teammateDetail') return null;
|
||||||
return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null
|
return teammateStatuses.find(t => t.name === dialogLevel.memberName) ?? null;
|
||||||
}, [dialogLevel, teammateStatuses])
|
}, [dialogLevel, teammateStatuses]);
|
||||||
|
|
||||||
// Get isBypassPermissionsModeAvailable from AppState
|
// Get isBypassPermissionsModeAvailable from AppState
|
||||||
const isBypassAvailable = useAppState(
|
const isBypassAvailable = useAppState(s => s.toolPermissionContext.isBypassPermissionsModeAvailable);
|
||||||
s => s.toolPermissionContext.isBypassPermissionsModeAvailable,
|
|
||||||
)
|
|
||||||
|
|
||||||
const goBackToList = (): void => {
|
const goBackToList = (): void => {
|
||||||
setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName })
|
setDialogLevel({ type: 'teammateList', teamName: dialogLevel.teamName });
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Handler for confirm:cycleMode - cycle teammate permission modes
|
// Handler for confirm:cycleMode - cycle teammate permission modes
|
||||||
const handleCycleMode = useCallback(() => {
|
const handleCycleMode = useCallback(() => {
|
||||||
if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
||||||
// Detail view: cycle just this teammate
|
// Detail view: cycle just this teammate
|
||||||
cycleTeammateMode(
|
cycleTeammateMode(currentTeammate, dialogLevel.teamName, isBypassAvailable);
|
||||||
currentTeammate,
|
setRefreshKey(k => k + 1);
|
||||||
dialogLevel.teamName,
|
} else if (dialogLevel.type === 'teammateList' && teammateStatuses.length > 0) {
|
||||||
isBypassAvailable,
|
|
||||||
)
|
|
||||||
setRefreshKey(k => k + 1)
|
|
||||||
} else if (
|
|
||||||
dialogLevel.type === 'teammateList' &&
|
|
||||||
teammateStatuses.length > 0
|
|
||||||
) {
|
|
||||||
// List view: cycle all teammates in tandem
|
// List view: cycle all teammates in tandem
|
||||||
cycleAllTeammateModes(
|
cycleAllTeammateModes(teammateStatuses, dialogLevel.teamName, isBypassAvailable);
|
||||||
teammateStatuses,
|
setRefreshKey(k => k + 1);
|
||||||
dialogLevel.teamName,
|
|
||||||
isBypassAvailable,
|
|
||||||
)
|
|
||||||
setRefreshKey(k => k + 1)
|
|
||||||
}
|
}
|
||||||
}, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable])
|
}, [dialogLevel, currentTeammate, teammateStatuses, isBypassAvailable]);
|
||||||
|
|
||||||
// Use keybindings for mode cycling
|
// Use keybindings for mode cycling
|
||||||
useKeybindings(
|
useKeybindings({ 'confirm:cycleMode': handleCycleMode }, { context: 'Confirmation' });
|
||||||
{ 'confirm:cycleMode': handleCycleMode },
|
|
||||||
{ context: 'Confirmation' },
|
|
||||||
)
|
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
// Handle left arrow to go back
|
// Handle left arrow to go back
|
||||||
if (key.leftArrow) {
|
if (key.leftArrow) {
|
||||||
if (dialogLevel.type === 'teammateDetail') {
|
if (dialogLevel.type === 'teammateDetail') {
|
||||||
goBackToList()
|
goBackToList();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle up/down navigation
|
// Handle up/down navigation
|
||||||
if (key.upArrow || key.downArrow) {
|
if (key.upArrow || key.downArrow) {
|
||||||
const maxIndex = getMaxIndex()
|
const maxIndex = getMaxIndex();
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
setSelectedIndex(prev => Math.max(0, prev - 1))
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||||
} else {
|
} else {
|
||||||
setSelectedIndex(prev => Math.min(maxIndex, prev + 1))
|
setSelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Enter to drill down or view output
|
// Handle Enter to drill down or view output
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
if (
|
if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) {
|
||||||
dialogLevel.type === 'teammateList' &&
|
|
||||||
teammateStatuses[selectedIndex]
|
|
||||||
) {
|
|
||||||
setDialogLevel({
|
setDialogLevel({
|
||||||
type: 'teammateDetail',
|
type: 'teammateDetail',
|
||||||
teamName: dialogLevel.teamName,
|
teamName: dialogLevel.teamName,
|
||||||
memberName: teammateStatuses[selectedIndex].name,
|
memberName: teammateStatuses[selectedIndex].name,
|
||||||
})
|
});
|
||||||
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
||||||
// View output - switch to tmux pane
|
// View output - switch to tmux pane
|
||||||
void viewTeammateOutput(
|
void viewTeammateOutput(
|
||||||
currentTeammate.tmuxPaneId,
|
currentTeammate.tmuxPaneId,
|
||||||
currentTeammate.backendType,
|
currentTeammate.backendType && isPaneBackend(currentTeammate.backendType)
|
||||||
)
|
? currentTeammate.backendType
|
||||||
onDone()
|
: undefined,
|
||||||
|
);
|
||||||
|
onDone();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 'k' to kill teammate
|
// Handle 'k' to kill teammate
|
||||||
if (input === 'k') {
|
if (input === 'k') {
|
||||||
if (
|
if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) {
|
||||||
dialogLevel.type === 'teammateList' &&
|
|
||||||
teammateStatuses[selectedIndex]
|
|
||||||
) {
|
|
||||||
void killTeammate(
|
void killTeammate(
|
||||||
teammateStatuses[selectedIndex].tmuxPaneId,
|
teammateStatuses[selectedIndex].tmuxPaneId,
|
||||||
teammateStatuses[selectedIndex].backendType,
|
teammateStatuses[selectedIndex].backendType && isPaneBackend(teammateStatuses[selectedIndex].backendType)
|
||||||
|
? teammateStatuses[selectedIndex].backendType
|
||||||
|
: undefined,
|
||||||
dialogLevel.teamName,
|
dialogLevel.teamName,
|
||||||
teammateStatuses[selectedIndex].agentId,
|
teammateStatuses[selectedIndex].agentId,
|
||||||
teammateStatuses[selectedIndex].name,
|
teammateStatuses[selectedIndex].name,
|
||||||
setAppState,
|
setAppState,
|
||||||
).then(() => {
|
).then(() => {
|
||||||
setRefreshKey(k => k + 1)
|
setRefreshKey(k => k + 1);
|
||||||
// Adjust selection if needed
|
// Adjust selection if needed
|
||||||
setSelectedIndex(prev =>
|
setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - 2)));
|
||||||
Math.max(0, Math.min(prev, teammateStatuses.length - 2)),
|
});
|
||||||
)
|
|
||||||
})
|
|
||||||
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
||||||
void killTeammate(
|
void killTeammate(
|
||||||
currentTeammate.tmuxPaneId,
|
currentTeammate.tmuxPaneId,
|
||||||
currentTeammate.backendType,
|
currentTeammate.backendType && isPaneBackend(currentTeammate.backendType)
|
||||||
|
? currentTeammate.backendType
|
||||||
|
: undefined,
|
||||||
dialogLevel.teamName,
|
dialogLevel.teamName,
|
||||||
currentTeammate.agentId,
|
currentTeammate.agentId,
|
||||||
currentTeammate.name,
|
currentTeammate.name,
|
||||||
setAppState,
|
setAppState,
|
||||||
)
|
);
|
||||||
goBackToList()
|
goBackToList();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 's' for shutdown of selected teammate
|
// Handle 's' for shutdown of selected teammate
|
||||||
if (input === 's') {
|
if (input === 's') {
|
||||||
if (
|
if (dialogLevel.type === 'teammateList' && teammateStatuses[selectedIndex]) {
|
||||||
dialogLevel.type === 'teammateList' &&
|
const teammate = teammateStatuses[selectedIndex];
|
||||||
teammateStatuses[selectedIndex]
|
|
||||||
) {
|
|
||||||
const teammate = teammateStatuses[selectedIndex]
|
|
||||||
void sendShutdownRequestToMailbox(
|
void sendShutdownRequestToMailbox(
|
||||||
teammate.name,
|
teammate.name,
|
||||||
dialogLevel.teamName,
|
dialogLevel.teamName,
|
||||||
'Graceful shutdown requested by team lead',
|
'Graceful shutdown requested by team lead',
|
||||||
)
|
);
|
||||||
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
} else if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
||||||
void sendShutdownRequestToMailbox(
|
void sendShutdownRequestToMailbox(
|
||||||
currentTeammate.name,
|
currentTeammate.name,
|
||||||
dialogLevel.teamName,
|
dialogLevel.teamName,
|
||||||
'Graceful shutdown requested by team lead',
|
'Graceful shutdown requested by team lead',
|
||||||
)
|
);
|
||||||
goBackToList()
|
goBackToList();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 'h' to hide/show individual teammate (only for backends that support it)
|
// Handle 'h' to hide/show individual teammate (only for backends that support it)
|
||||||
if (input === 'h') {
|
if (input === 'h') {
|
||||||
const backend = getCachedBackend()
|
const backend = getCachedBackend();
|
||||||
const teammate =
|
const teammate =
|
||||||
dialogLevel.type === 'teammateList'
|
dialogLevel.type === 'teammateList'
|
||||||
? teammateStatuses[selectedIndex]
|
? teammateStatuses[selectedIndex]
|
||||||
: dialogLevel.type === 'teammateDetail'
|
: dialogLevel.type === 'teammateDetail'
|
||||||
? currentTeammate
|
? currentTeammate
|
||||||
: null
|
: null;
|
||||||
|
|
||||||
if (teammate && backend?.supportsHideShow) {
|
if (teammate && backend?.supportsHideShow) {
|
||||||
void toggleTeammateVisibility(teammate, dialogLevel.teamName).then(
|
void toggleTeammateVisibility(teammate, dialogLevel.teamName).then(() => {
|
||||||
() => {
|
// Force refresh of teammate statuses
|
||||||
// Force refresh of teammate statuses
|
setRefreshKey(k => k + 1);
|
||||||
setRefreshKey(k => k + 1)
|
});
|
||||||
},
|
|
||||||
)
|
|
||||||
if (dialogLevel.type === 'teammateDetail') {
|
if (dialogLevel.type === 'teammateDetail') {
|
||||||
goBackToList()
|
goBackToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 'H' to hide/show all teammates (only for backends that support it)
|
// Handle 'H' to hide/show all teammates (only for backends that support it)
|
||||||
if (input === 'H' && dialogLevel.type === 'teammateList') {
|
if (input === 'H' && dialogLevel.type === 'teammateList') {
|
||||||
const backend = getCachedBackend()
|
const backend = getCachedBackend();
|
||||||
if (backend?.supportsHideShow && teammateStatuses.length > 0) {
|
if (backend?.supportsHideShow && teammateStatuses.length > 0) {
|
||||||
// If any are visible, hide all. Otherwise, show all.
|
// If any are visible, hide all. Otherwise, show all.
|
||||||
const anyVisible = teammateStatuses.some(t => !t.isHidden)
|
const anyVisible = teammateStatuses.some(t => !t.isHidden);
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
teammateStatuses.map(t =>
|
teammateStatuses.map(t =>
|
||||||
anyVisible
|
anyVisible ? hideTeammate(t, dialogLevel.teamName) : showTeammate(t, dialogLevel.teamName),
|
||||||
? hideTeammate(t, dialogLevel.teamName)
|
|
||||||
: showTeammate(t, dialogLevel.teamName),
|
|
||||||
),
|
),
|
||||||
).then(() => {
|
).then(() => {
|
||||||
// Force refresh of teammate statuses
|
// Force refresh of teammate statuses
|
||||||
setRefreshKey(k => k + 1)
|
setRefreshKey(k => k + 1);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 'p' to prune (kill) all idle teammates
|
// Handle 'p' to prune (kill) all idle teammates
|
||||||
if (input === 'p' && dialogLevel.type === 'teammateList') {
|
if (input === 'p' && dialogLevel.type === 'teammateList') {
|
||||||
const idleTeammates = teammateStatuses.filter(t => t.status === 'idle')
|
const idleTeammates = teammateStatuses.filter(t => t.status === 'idle');
|
||||||
if (idleTeammates.length > 0) {
|
if (idleTeammates.length > 0) {
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
idleTeammates.map(t =>
|
idleTeammates.map(t =>
|
||||||
killTeammate(
|
killTeammate(
|
||||||
t.tmuxPaneId,
|
t.tmuxPaneId,
|
||||||
t.backendType,
|
t.backendType && isPaneBackend(t.backendType) ? t.backendType : undefined,
|
||||||
dialogLevel.teamName,
|
dialogLevel.teamName,
|
||||||
t.agentId,
|
t.agentId,
|
||||||
t.name,
|
t.name,
|
||||||
@@ -311,29 +264,21 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then(() => {
|
).then(() => {
|
||||||
setRefreshKey(k => k + 1)
|
setRefreshKey(k => k + 1);
|
||||||
setSelectedIndex(prev =>
|
setSelectedIndex(prev => Math.max(0, Math.min(prev, teammateStatuses.length - idleTeammates.length - 1)));
|
||||||
Math.max(
|
});
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
prev,
|
|
||||||
teammateStatuses.length - idleTeammates.length - 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Mode cycling (shift+tab) is handled via useKeybindings with confirm:cycleMode action
|
// Note: Mode cycling (shift+tab) is handled via useKeybindings with confirm:cycleMode action
|
||||||
})
|
});
|
||||||
|
|
||||||
function getMaxIndex(): number {
|
function getMaxIndex(): number {
|
||||||
if (dialogLevel.type === 'teammateList') {
|
if (dialogLevel.type === 'teammateList') {
|
||||||
return Math.max(0, teammateStatuses.length - 1)
|
return Math.max(0, teammateStatuses.length - 1);
|
||||||
}
|
}
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render based on dialog level
|
// Render based on dialog level
|
||||||
@@ -345,215 +290,150 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
|||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onCancel={onDone}
|
onCancel={onDone}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
if (dialogLevel.type === 'teammateDetail' && currentTeammate) {
|
||||||
return (
|
return <TeammateDetailView teammate={currentTeammate} teamName={dialogLevel.teamName} onCancel={goBackToList} />;
|
||||||
<TeammateDetailView
|
|
||||||
teammate={currentTeammate}
|
|
||||||
teamName={dialogLevel.teamName}
|
|
||||||
onCancel={goBackToList}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamDetailViewProps = {
|
type TeamDetailViewProps = {
|
||||||
teamName: string
|
teamName: string;
|
||||||
teammates: TeammateStatus[]
|
teammates: TeammateStatus[];
|
||||||
selectedIndex: number
|
selectedIndex: number;
|
||||||
onCancel: () => void
|
onCancel: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
function TeamDetailView({
|
function TeamDetailView({ teamName, teammates, selectedIndex, onCancel }: TeamDetailViewProps): React.ReactNode {
|
||||||
teamName,
|
const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}`;
|
||||||
teammates,
|
|
||||||
selectedIndex,
|
|
||||||
onCancel,
|
|
||||||
}: TeamDetailViewProps): React.ReactNode {
|
|
||||||
const subtitle = `${teammates.length} ${teammates.length === 1 ? 'teammate' : 'teammates'}`
|
|
||||||
// Check if the backend supports hide/show
|
// Check if the backend supports hide/show
|
||||||
const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false
|
const supportsHideShow = getCachedBackend()?.supportsHideShow ?? false;
|
||||||
// Get the display text for the cycle mode shortcut
|
// Get the display text for the cycle mode shortcut
|
||||||
const cycleModeShortcut = useShortcutDisplay(
|
const cycleModeShortcut = useShortcutDisplay('confirm:cycleMode', 'Confirmation', 'shift+tab');
|
||||||
'confirm:cycleMode',
|
|
||||||
'Confirmation',
|
|
||||||
'shift+tab',
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog title={`Team ${teamName}`} subtitle={subtitle} onCancel={onCancel} color="background" hideInputGuide>
|
||||||
title={`Team ${teamName}`}
|
|
||||||
subtitle={subtitle}
|
|
||||||
onCancel={onCancel}
|
|
||||||
color="background"
|
|
||||||
hideInputGuide
|
|
||||||
>
|
|
||||||
{teammates.length === 0 ? (
|
{teammates.length === 0 ? (
|
||||||
<Text dimColor>No teammates</Text>
|
<Text dimColor>No teammates</Text>
|
||||||
) : (
|
) : (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{teammates.map((teammate, index) => (
|
{teammates.map((teammate, index) => (
|
||||||
<TeammateListItem
|
<TeammateListItem key={teammate.agentId} teammate={teammate} isSelected={index === selectedIndex} />
|
||||||
key={teammate.agentId}
|
|
||||||
teammate={teammate}
|
|
||||||
isSelected={index === selectedIndex}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Box marginLeft={1}>
|
<Box marginLeft={1}>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s
|
{figures.arrowUp}/{figures.arrowDown} select · Enter view · k kill · s shutdown · p prune idle
|
||||||
shutdown · p prune idle
|
|
||||||
{supportsHideShow && ' · h hide/show · H hide/show all'}
|
{supportsHideShow && ' · h hide/show · H hide/show all'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{cycleModeShortcut} sync cycle modes for all · Esc close
|
{cycleModeShortcut} sync cycle modes for all · Esc close
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeammateListItemProps = {
|
type TeammateListItemProps = {
|
||||||
teammate: TeammateStatus
|
teammate: TeammateStatus;
|
||||||
isSelected: boolean
|
isSelected: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
function TeammateListItem({
|
function TeammateListItem({ teammate, isSelected }: TeammateListItemProps): React.ReactNode {
|
||||||
teammate,
|
const isIdle = teammate.status === 'idle';
|
||||||
isSelected,
|
|
||||||
}: TeammateListItemProps): React.ReactNode {
|
|
||||||
const isIdle = teammate.status === 'idle'
|
|
||||||
// Only dim if idle AND not selected - selection highlighting takes precedence
|
// Only dim if idle AND not selected - selection highlighting takes precedence
|
||||||
const shouldDim = isIdle && !isSelected
|
const shouldDim = isIdle && !isSelected;
|
||||||
|
|
||||||
// Get mode display
|
// Get mode display
|
||||||
const mode = teammate.mode
|
const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default';
|
||||||
? permissionModeFromString(teammate.mode)
|
const modeSymbol = permissionModeSymbol(mode);
|
||||||
: 'default'
|
const modeColor = getModeColor(mode);
|
||||||
const modeSymbol = permissionModeSymbol(mode)
|
|
||||||
const modeColor = getModeColor(mode)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text color={isSelected ? 'suggestion' : undefined} dimColor={shouldDim}>
|
<Text color={isSelected ? 'suggestion' : undefined} dimColor={shouldDim}>
|
||||||
{isSelected ? figures.pointer + ' ' : ' '}
|
{isSelected ? figures.pointer + ' ' : ' '}
|
||||||
{teammate.isHidden && <Text dimColor>[hidden] </Text>}
|
{teammate.isHidden && <Text dimColor>[hidden] </Text>}
|
||||||
{isIdle && <Text dimColor>[idle] </Text>}
|
{isIdle && <Text dimColor>[idle] </Text>}
|
||||||
{modeSymbol && <Text color={modeColor}>{modeSymbol} </Text>}@
|
{modeSymbol && <Text color={modeColor}>{modeSymbol} </Text>}@{teammate.name}
|
||||||
{teammate.name}
|
|
||||||
{teammate.model && <Text dimColor> ({teammate.model})</Text>}
|
{teammate.model && <Text dimColor> ({teammate.model})</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeammateDetailViewProps = {
|
type TeammateDetailViewProps = {
|
||||||
teammate: TeammateStatus
|
teammate: TeammateStatus;
|
||||||
teamName: string
|
teamName: string;
|
||||||
onCancel: () => void
|
onCancel: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
function TeammateDetailView({
|
function TeammateDetailView({ teammate, teamName, onCancel }: TeammateDetailViewProps): React.ReactNode {
|
||||||
teammate,
|
const [promptExpanded, setPromptExpanded] = useState(false);
|
||||||
teamName,
|
|
||||||
onCancel,
|
|
||||||
}: TeammateDetailViewProps): React.ReactNode {
|
|
||||||
const [promptExpanded, setPromptExpanded] = useState(false)
|
|
||||||
// Get the display text for the cycle mode shortcut
|
// Get the display text for the cycle mode shortcut
|
||||||
const cycleModeShortcut = useShortcutDisplay(
|
const cycleModeShortcut = useShortcutDisplay('confirm:cycleMode', 'Confirmation', 'shift+tab');
|
||||||
'confirm:cycleMode',
|
|
||||||
'Confirmation',
|
|
||||||
'shift+tab',
|
|
||||||
)
|
|
||||||
const themeColor = teammate.color
|
const themeColor = teammate.color
|
||||||
? AGENT_COLOR_TO_THEME_COLOR[
|
? AGENT_COLOR_TO_THEME_COLOR[teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR]
|
||||||
teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR
|
: undefined;
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Get tasks assigned to this teammate
|
// Get tasks assigned to this teammate
|
||||||
const [teammateTasks, setTeammateTasks] = useState<Task[]>([])
|
const [teammateTasks, setTeammateTasks] = useState<Task[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
void listTasks(teamName).then(allTasks => {
|
void listTasks(teamName).then(allTasks => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
// Filter tasks owned by this teammate (by agentId or name)
|
// Filter tasks owned by this teammate (by agentId or name)
|
||||||
setTeammateTasks(
|
setTeammateTasks(allTasks.filter(task => task.owner === teammate.agentId || task.owner === teammate.name));
|
||||||
allTasks.filter(
|
});
|
||||||
task =>
|
|
||||||
task.owner === teammate.agentId || task.owner === teammate.name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [teamName, teammate.agentId, teammate.name])
|
}, [teamName, teammate.agentId, teammate.name]);
|
||||||
|
|
||||||
useInput(input => {
|
useInput(input => {
|
||||||
// Handle 'p' to expand/collapse prompt
|
// Handle 'p' to expand/collapse prompt
|
||||||
if (input === 'p') {
|
if (input === 'p') {
|
||||||
setPromptExpanded(prev => !prev)
|
setPromptExpanded(prev => !prev);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Determine working directory display
|
// Determine working directory display
|
||||||
const workingPath = teammate.worktreePath || teammate.cwd
|
const workingPath = teammate.worktreePath || teammate.cwd;
|
||||||
|
|
||||||
// Build subtitle with metadata
|
// Build subtitle with metadata
|
||||||
const subtitleParts: string[] = []
|
const subtitleParts: string[] = [];
|
||||||
if (teammate.model) subtitleParts.push(teammate.model)
|
if (teammate.model) subtitleParts.push(teammate.model);
|
||||||
if (workingPath) {
|
if (workingPath) {
|
||||||
subtitleParts.push(
|
subtitleParts.push(teammate.worktreePath ? `worktree: ${workingPath}` : workingPath);
|
||||||
teammate.worktreePath ? `worktree: ${workingPath}` : workingPath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const subtitle = subtitleParts.join(' · ') || undefined
|
const subtitle = subtitleParts.join(' · ') || undefined;
|
||||||
|
|
||||||
// Get mode display for title
|
// Get mode display for title
|
||||||
const mode = teammate.mode
|
const mode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default';
|
||||||
? permissionModeFromString(teammate.mode)
|
const modeSymbol = permissionModeSymbol(mode);
|
||||||
: 'default'
|
const modeColor = getModeColor(mode);
|
||||||
const modeSymbol = permissionModeSymbol(mode)
|
|
||||||
const modeColor = getModeColor(mode)
|
|
||||||
|
|
||||||
// Build title with mode symbol and colored name if applicable
|
// Build title with mode symbol and colored name if applicable
|
||||||
const title = (
|
const title = (
|
||||||
<>
|
<>
|
||||||
{modeSymbol && <Text color={modeColor}>{modeSymbol} </Text>}
|
{modeSymbol && <Text color={modeColor}>{modeSymbol} </Text>}
|
||||||
{themeColor ? (
|
{themeColor ? <ThemedText color={themeColor}>{`@${teammate.name}`}</ThemedText> : `@${teammate.name}`}
|
||||||
<ThemedText color={themeColor}>{`@${teammate.name}`}</ThemedText>
|
|
||||||
) : (
|
|
||||||
`@${teammate.name}`
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog title={title} subtitle={subtitle} onCancel={onCancel} color="background" hideInputGuide>
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
onCancel={onCancel}
|
|
||||||
color="background"
|
|
||||||
hideInputGuide
|
|
||||||
>
|
|
||||||
{/* Tasks section */}
|
{/* Tasks section */}
|
||||||
{teammateTasks.length > 0 && (
|
{teammateTasks.length > 0 && (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold>Tasks</Text>
|
<Text bold>Tasks</Text>
|
||||||
{teammateTasks.map(task => (
|
{teammateTasks.map(task => (
|
||||||
<Text
|
<Text key={task.id} color={task.status === 'completed' ? 'success' : undefined}>
|
||||||
key={task.id}
|
{task.status === 'completed' ? figures.tick : '◼'} {task.subject}
|
||||||
color={task.status === 'completed' ? 'success' : undefined}
|
|
||||||
>
|
|
||||||
{task.status === 'completed' ? figures.tick : '◼'}{' '}
|
|
||||||
{task.subject}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -564,12 +444,8 @@ function TeammateDetailView({
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold>Prompt</Text>
|
<Text bold>Prompt</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{promptExpanded
|
{promptExpanded ? teammate.prompt : truncateToWidth(teammate.prompt, 80)}
|
||||||
? teammate.prompt
|
{stringWidth(teammate.prompt) > 80 && !promptExpanded && <Text dimColor> (p to expand)</Text>}
|
||||||
: truncateToWidth(teammate.prompt, 80)}
|
|
||||||
{stringWidth(teammate.prompt) > 80 && !promptExpanded && (
|
|
||||||
<Text dimColor> (p to expand)</Text>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -583,7 +459,7 @@ function TeammateDetailView({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killTeammate(
|
async function killTeammate(
|
||||||
@@ -602,36 +478,28 @@ async function killTeammate(
|
|||||||
// Use ensureBackendsRegistered (not detectAndGetBackend) — this process may
|
// Use ensureBackendsRegistered (not detectAndGetBackend) — this process may
|
||||||
// be a teammate that never ran detection, but we only need class imports
|
// be a teammate that never ran detection, but we only need class imports
|
||||||
// here, not subprocess probes that could throw in a different environment.
|
// here, not subprocess probes that could throw in a different environment.
|
||||||
await ensureBackendsRegistered()
|
await ensureBackendsRegistered();
|
||||||
await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync())
|
await getBackendByType(backendType).killPane(paneId, !isInsideTmuxSync());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`)
|
logForDebugging(`[TeamsDialog] Failed to kill pane ${paneId}: ${error}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// backendType undefined: old team files predating this field, or in-process.
|
// backendType undefined: old team files predating this field, or in-process.
|
||||||
// Old tmux-file case is a migration gap — the pane is orphaned. In-process
|
// Old tmux-file case is a migration gap — the pane is orphaned. In-process
|
||||||
// teammates have no pane to kill, so this is correct for them.
|
// teammates have no pane to kill, so this is correct for them.
|
||||||
logForDebugging(
|
logForDebugging(`[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`);
|
||||||
`[TeamsDialog] Skipping pane kill for ${paneId}: no backendType recorded`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Remove from team config file
|
// Remove from team config file
|
||||||
removeMemberFromTeam(teamName, paneId)
|
removeMemberFromTeam(teamName, paneId);
|
||||||
|
|
||||||
// Unassign tasks and build notification message
|
// Unassign tasks and build notification message
|
||||||
const { notificationMessage } = await unassignTeammateTasks(
|
const { notificationMessage } = await unassignTeammateTasks(teamName, teammateId, teammateName, 'terminated');
|
||||||
teamName,
|
|
||||||
teammateId,
|
|
||||||
teammateName,
|
|
||||||
'terminated',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update AppState to keep status line in sync and notify the lead
|
// Update AppState to keep status line in sync and notify the lead
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
if (!prev.teamContext?.teammates) return prev
|
if (!prev.teamContext?.teammates) return prev;
|
||||||
if (!(teammateId in prev.teamContext.teammates)) return prev
|
if (!(teammateId in prev.teamContext.teammates)) return prev;
|
||||||
const { [teammateId]: _, ...remainingTeammates } =
|
const { [teammateId]: _, ...remainingTeammates } = prev.teamContext.teammates;
|
||||||
prev.teamContext.teammates
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
teamContext: {
|
teamContext: {
|
||||||
@@ -653,40 +521,39 @@ async function killTeammate(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`)
|
logForDebugging(`[TeamsDialog] Removed ${teammateId} from teamContext`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewTeammateOutput(
|
async function viewTeammateOutput(paneId: string, backendType: PaneBackendType | undefined): Promise<void> {
|
||||||
paneId: string,
|
|
||||||
backendType: PaneBackendType | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
if (backendType === 'iterm2') {
|
if (backendType === 'iterm2') {
|
||||||
// -s is required to target a specific session (ITermBackend.ts:216-217)
|
// -s is required to target a specific session (ITermBackend.ts:216-217)
|
||||||
await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId])
|
await execFileNoThrow(IT2_COMMAND, ['session', 'focus', '-s', paneId]);
|
||||||
|
} else if (backendType === 'windows-terminal') {
|
||||||
|
// Windows Terminal spawns each teammate as a separate window/tab; wt.exe
|
||||||
|
// does not expose an API to focus a pre-existing tab by name. The user
|
||||||
|
// switches tabs manually (Ctrl+Tab) — dialog closing is enough here.
|
||||||
|
logForDebugging(`[TeamsDialog] viewTeammateOutput: Windows Terminal pane ${paneId} — manual tab switch required`);
|
||||||
} else {
|
} else {
|
||||||
// External-tmux teammates live on the swarm socket — without -L, this
|
// External-tmux teammates live on the swarm socket — without -L, this
|
||||||
// targets the default server and silently no-ops. Mirrors runTmuxInSwarm
|
// targets the default server and silently no-ops. Mirrors runTmuxInSwarm
|
||||||
// in TmuxBackend.ts:85-89.
|
// in TmuxBackend.ts:85-89.
|
||||||
const args = isInsideTmuxSync()
|
const args = isInsideTmuxSync()
|
||||||
? ['select-pane', '-t', paneId]
|
? ['select-pane', '-t', paneId]
|
||||||
: ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId]
|
: ['-L', getSwarmSocketName(), 'select-pane', '-t', paneId];
|
||||||
await execFileNoThrow(TMUX_COMMAND, args)
|
await execFileNoThrow(TMUX_COMMAND, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle visibility of a teammate pane (hide if visible, show if hidden)
|
* Toggle visibility of a teammate pane (hide if visible, show if hidden)
|
||||||
*/
|
*/
|
||||||
async function toggleTeammateVisibility(
|
async function toggleTeammateVisibility(teammate: TeammateStatus, teamName: string): Promise<void> {
|
||||||
teammate: TeammateStatus,
|
|
||||||
teamName: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (teammate.isHidden) {
|
if (teammate.isHidden) {
|
||||||
await showTeammate(teammate, teamName)
|
await showTeammate(teammate, teamName);
|
||||||
} else {
|
} else {
|
||||||
await hideTeammate(teammate, teamName)
|
await hideTeammate(teammate, teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,39 +561,27 @@ async function toggleTeammateVisibility(
|
|||||||
* Hide a teammate pane using the backend abstraction.
|
* Hide a teammate pane using the backend abstraction.
|
||||||
* Only available for ant users (gated for dead code elimination in external builds)
|
* Only available for ant users (gated for dead code elimination in external builds)
|
||||||
*/
|
*/
|
||||||
async function hideTeammate(
|
async function hideTeammate(teammate: TeammateStatus, teamName: string): Promise<void> {}
|
||||||
teammate: TeammateStatus,
|
|
||||||
teamName: string,
|
|
||||||
): Promise<void> {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a previously hidden teammate pane using the backend abstraction.
|
* Show a previously hidden teammate pane using the backend abstraction.
|
||||||
* Only available for ant users (gated for dead code elimination in external builds)
|
* Only available for ant users (gated for dead code elimination in external builds)
|
||||||
*/
|
*/
|
||||||
async function showTeammate(
|
async function showTeammate(teammate: TeammateStatus, teamName: string): Promise<void> {}
|
||||||
teammate: TeammateStatus,
|
|
||||||
teamName: string,
|
|
||||||
): Promise<void> {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a mode change message to a single teammate
|
* Send a mode change message to a single teammate
|
||||||
* Also updates config.json directly so the UI reflects the change immediately
|
* Also updates config.json directly so the UI reflects the change immediately
|
||||||
*/
|
*/
|
||||||
function sendModeChangeToTeammate(
|
function sendModeChangeToTeammate(teammateName: string, teamName: string, targetMode: PermissionMode): void {
|
||||||
teammateName: string,
|
|
||||||
teamName: string,
|
|
||||||
targetMode: PermissionMode,
|
|
||||||
): void {
|
|
||||||
// Update config.json directly so UI shows the change immediately
|
// Update config.json directly so UI shows the change immediately
|
||||||
setMemberMode(teamName, teammateName, targetMode)
|
setMemberMode(teamName, teammateName, targetMode);
|
||||||
|
|
||||||
// Also send message so teammate updates their local permission context
|
// Also send message so teammate updates their local permission context
|
||||||
const message = createModeSetRequestMessage({
|
const message = createModeSetRequestMessage({
|
||||||
mode: targetMode,
|
mode: targetMode,
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
})
|
});
|
||||||
void writeToMailbox(
|
void writeToMailbox(
|
||||||
teammateName,
|
teammateName,
|
||||||
{
|
{
|
||||||
@@ -735,30 +590,22 @@ function sendModeChangeToTeammate(
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
teamName,
|
teamName,
|
||||||
)
|
);
|
||||||
logForDebugging(
|
logForDebugging(`[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`);
|
||||||
`[TeamsDialog] Sent mode change to ${teammateName}: ${targetMode}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cycle a single teammate's mode
|
* Cycle a single teammate's mode
|
||||||
*/
|
*/
|
||||||
function cycleTeammateMode(
|
function cycleTeammateMode(teammate: TeammateStatus, teamName: string, isBypassAvailable: boolean): void {
|
||||||
teammate: TeammateStatus,
|
const currentMode = teammate.mode ? permissionModeFromString(teammate.mode) : 'default';
|
||||||
teamName: string,
|
|
||||||
isBypassAvailable: boolean,
|
|
||||||
): void {
|
|
||||||
const currentMode = teammate.mode
|
|
||||||
? permissionModeFromString(teammate.mode)
|
|
||||||
: 'default'
|
|
||||||
const context = {
|
const context = {
|
||||||
...getEmptyToolPermissionContext(),
|
...getEmptyToolPermissionContext(),
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||||
}
|
};
|
||||||
const nextMode = getNextPermissionMode(context)
|
const nextMode = getNextPermissionMode(context);
|
||||||
sendModeChangeToTeammate(teammate.name, teamName, nextMode)
|
sendModeChangeToTeammate(teammate.name, teamName, nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -767,17 +614,11 @@ function cycleTeammateMode(
|
|||||||
* If same, cycle all to next mode
|
* If same, cycle all to next mode
|
||||||
* Uses batch update to avoid race conditions
|
* Uses batch update to avoid race conditions
|
||||||
*/
|
*/
|
||||||
function cycleAllTeammateModes(
|
function cycleAllTeammateModes(teammates: TeammateStatus[], teamName: string, isBypassAvailable: boolean): void {
|
||||||
teammates: TeammateStatus[],
|
if (teammates.length === 0) return;
|
||||||
teamName: string,
|
|
||||||
isBypassAvailable: boolean,
|
|
||||||
): void {
|
|
||||||
if (teammates.length === 0) return
|
|
||||||
|
|
||||||
const modes = teammates.map(t =>
|
const modes = teammates.map(t => (t.mode ? permissionModeFromString(t.mode) : 'default'));
|
||||||
t.mode ? permissionModeFromString(t.mode) : 'default',
|
const allSame = modes.every(m => m === modes[0]);
|
||||||
)
|
|
||||||
const allSame = modes.every(m => m === modes[0])
|
|
||||||
|
|
||||||
// Determine target mode for all teammates
|
// Determine target mode for all teammates
|
||||||
const targetMode = !allSame
|
const targetMode = !allSame
|
||||||
@@ -786,21 +627,21 @@ function cycleAllTeammateModes(
|
|||||||
...getEmptyToolPermissionContext(),
|
...getEmptyToolPermissionContext(),
|
||||||
mode: modes[0] ?? 'default',
|
mode: modes[0] ?? 'default',
|
||||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Batch update config.json in a single atomic operation
|
// Batch update config.json in a single atomic operation
|
||||||
const modeUpdates = teammates.map(t => ({
|
const modeUpdates = teammates.map(t => ({
|
||||||
memberName: t.name,
|
memberName: t.name,
|
||||||
mode: targetMode,
|
mode: targetMode,
|
||||||
}))
|
}));
|
||||||
setMultipleMemberModes(teamName, modeUpdates)
|
setMultipleMemberModes(teamName, modeUpdates);
|
||||||
|
|
||||||
// Send mailbox messages to each teammate
|
// Send mailbox messages to each teammate
|
||||||
for (const teammate of teammates) {
|
for (const teammate of teammates) {
|
||||||
const message = createModeSetRequestMessage({
|
const message = createModeSetRequestMessage({
|
||||||
mode: targetMode,
|
mode: targetMode,
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
})
|
});
|
||||||
void writeToMailbox(
|
void writeToMailbox(
|
||||||
teammate.name,
|
teammate.name,
|
||||||
{
|
{
|
||||||
@@ -809,9 +650,7 @@ function cycleAllTeammateModes(
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
teamName,
|
teamName,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
logForDebugging(
|
logForDebugging(`[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`);
|
||||||
`[TeamsDialog] Sent mode change to all ${teammates.length} teammates: ${targetMode}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user